Skip to content

Commit 7d113c2

Browse files
committed
examples: rewrite clients around v0.2.0 ops (delete, tournament, MCP)
The three clients used to be near-duplicate 'hand-rolled branch loop' demos hitting only head/exec/checkout. Each now shows a distinct v0.2.0 pattern: - branch_explore.py (socket): kept the manual exploration loop and added a 'delete' pass that prunes the losing branches from the DAG. - goclient/main.go (socket): rewritten around the high-level 'tournament' op — one round-trip, the daemon runs the candidates in parallel workspaces. Same 'delete' pruning at the end. - mcp_client.py (NEW): drives 'agentenv mcp' over JSON-RPC stdio from any language, no Claude Code needed — calls agentenv__log, __branches, __delete. Shows the MCP surface that powers self-rollback is portable. Removed Client.java — it added nothing the other two socket clients didn't already cover, and three near-identical socket demos was just noise. examples/README updated to reflect the new lineup.
1 parent eda6a5e commit 7d113c2

6 files changed

Lines changed: 196 additions & 131 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,7 @@ coverage.txt
3737

3838
# Recording tool binaries (fetched via scripts/fetch-recording-tools.sh)
3939
/scripts/.recorder-bin/
40+
41+
# Python
42+
__pycache__/
43+
*.pyc

examples/Client.java

Lines changed: 0 additions & 62 deletions
This file was deleted.

examples/README.md

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,14 @@ Error frames have `{"error":"…"}` instead of `{"ok":true,…}`. Streaming ops
4646

4747
## Clients (all stdlib, no dependencies)
4848

49-
| File | Language | Run |
50-
|------|----------|-----|
51-
| `branch_explore.py` | Python | `python3 branch_explore.py <sock>` |
52-
| `goclient/main.go` | Go | `go run goclient/main.go <sock>` |
53-
| `Client.java` | Java 16+| `javac Client.java -d /tmp && java -cp /tmp Client <sock>` |
54-
55-
Each forks the environment from one base, tries three candidate environments in
56-
parallel DAG branches (each in its own isolated workspace), and keeps the one
57-
that passes the test — purely over the socket.
49+
Each demonstrates a distinct v0.2.0 pattern, so they're complements rather than
50+
translations of each other.
51+
52+
| File | Driver | Pattern |
53+
|------|--------|---------|
54+
| `branch_explore.py` | Socket (NDJSON) | Hand-rolled exploration: fork base → try 3 candidates → keep winner → **`delete` the losing branches** |
55+
| `goclient/main.go` | Socket (NDJSON) | High-level `tournament` op: one round-trip, daemon runs the candidates in parallel workspaces; then `delete` the losers |
56+
| `mcp_client.py` | MCP (JSON-RPC over stdio) | Drive `agentenv mcp` from any language (no Claude Code needed); calls `agentenv__log` / `__branches` / `__delete` |
5857

5958
## Simplest driver: the CLI
6059

examples/branch_explore.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ def head(self):
6565
def checkout(self, node):
6666
return self.call(op="checkout", node=node)["head"]
6767

68+
def delete(self, node):
69+
"""Remove a node from the DAG (children re-parent). Used to prune
70+
dead-end exploration branches once we know they lost."""
71+
return self.call(op="delete", node=node)
72+
6873

6974
def main():
7075
sock = sys.argv[1] if len(sys.argv) > 1 else "/agentfs/agentenv.sock"
@@ -93,13 +98,20 @@ def main():
9398
winner = name
9499
print("winner:", winner)
95100

96-
# Keep the winner; the other branches remain in history but are not HEAD.
101+
# Keep the winner; PRUNE the losing branches with `delete` (v0.2.0) so the
102+
# DAG doesn't keep dead-end snapshots forever. Children of a deleted node
103+
# re-parent to its parent — harmless here because the losers are leaves.
97104
env.checkout(tips[winner])
105+
for name, tip in tips.items():
106+
if name != winner:
107+
env.delete(tip)
108+
print(f" pruned dead-end {name} (node {tip})")
109+
98110
have = env.exec("command -v jq && (command -v tree || echo no-tree) && (command -v figlet || echo no-figlet)")
99111
print("final env:\n" + have["stdout"].rstrip())
100112

101113
if winner == "B":
102-
print("PASS: agent explored 3 environments over the socket and kept the winner")
114+
print("PASS: agent explored 3 environments, kept the winner, pruned the losers")
103115
else:
104116
print("FAIL: unexpected winner", winner)
105117
sys.exit(1)

examples/goclient/main.go

Lines changed: 65 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
//go:build ignore
22

3-
// A Go client for the agentenv JSON socket API — same branch-exploration as the
4-
// Python example, showing the protocol is language-neutral (stdlib only).
3+
// A Go client for agentenv that drives the high-level `tournament` op — the
4+
// daemon forks N parallel branches from a base, runs each candidate plus a test
5+
// command, and tells you which branch passed first. Compared to the manual
6+
// loop in ../branch_explore.py (head → checkout → exec → check, repeat), it's
7+
// one round-trip and the parallelism happens inside agentenv (true parallel
8+
// workspaces, not serial like a hand-rolled loop).
59
//
610
// Run: go run examples/goclient/main.go /agentfs/agentenv.sock
711
package main
@@ -14,79 +18,82 @@ import (
1418
"os"
1519
)
1620

17-
type resp struct {
18-
OK bool `json:"ok"`
19-
Error string `json:"error"`
20-
Exit *int `json:"exit"`
21-
Stdout string `json:"stdout"`
22-
Head string `json:"head"`
23-
Node string `json:"node"`
24-
}
25-
26-
type client struct {
27-
conn net.Conn
28-
r *bufio.Reader
21+
type branch struct {
22+
Name string `json:"name"`
23+
Cmd string `json:"cmd"`
24+
Node string `json:"node"`
25+
TestExit int `json:"test_exit"`
2926
}
3027

31-
func dial(sock string) *client {
32-
c, err := net.Dial("unix", sock)
33-
if err != nil {
34-
panic(err)
35-
}
36-
return &client{conn: c, r: bufio.NewReader(c)}
28+
type resp struct {
29+
OK bool `json:"ok"`
30+
Error string `json:"error"`
31+
Head string `json:"head"`
32+
Branches []branch `json:"branches"`
33+
Winner string `json:"winner"`
34+
WinnerNode string `json:"winner_node"`
3735
}
3836

39-
// call sends one request and returns the TERMINAL frame. For "exec", agentenv
40-
// streams stdout/stderr frames first; we drain them until we see a frame with
41-
// "ok":true or "error". Other ops return one frame, so the loop exits after one
42-
// iteration.
43-
func (c *client) call(req map[string]any) resp {
37+
// call sends one request and returns the terminal frame. The tournament op is
38+
// single-frame; streaming ops (exec) would need draining first.
39+
func call(c net.Conn, r *bufio.Reader, req map[string]any) resp {
4440
b, _ := json.Marshal(req)
45-
c.conn.Write(append(b, '\n'))
46-
for {
47-
line, err := c.r.ReadBytes('\n')
48-
if err != nil {
49-
panic(err)
50-
}
51-
var out resp
52-
json.Unmarshal(line, &out)
53-
if out.Error != "" {
54-
panic("api error: " + out.Error)
55-
}
56-
if out.OK {
57-
return out
58-
}
59-
// streaming output frame: drop on the floor in this minimal example
60-
// (a real client would print or buffer it).
41+
c.Write(append(b, '\n'))
42+
line, _ := r.ReadBytes('\n')
43+
var out resp
44+
json.Unmarshal(line, &out)
45+
if out.Error != "" {
46+
panic("api error: " + out.Error)
6147
}
48+
return out
6249
}
6350

6451
func main() {
6552
sock := "/agentfs/agentenv.sock"
6653
if len(os.Args) > 1 {
6754
sock = os.Args[1]
6855
}
69-
c := dial(sock)
56+
c, err := net.Dial("unix", sock)
57+
if err != nil {
58+
panic(err)
59+
}
60+
defer c.Close()
61+
r := bufio.NewReader(c)
7062

71-
base := c.call(map[string]any{"op": "head"}).Head
72-
fmt.Println("base:", base)
63+
apt := "apt-get -o APT::Sandbox::User=root install -y -qq"
64+
req := map[string]any{
65+
"op": "tournament",
66+
"base": "HEAD",
67+
"test": "command -v jq",
68+
"candidates": []map[string]string{
69+
{"name": "A", "cmd": apt + " tree"},
70+
{"name": "B", "cmd": apt + " jq"},
71+
{"name": "C", "cmd": apt + " figlet"},
72+
},
73+
}
74+
out := call(c, r, req)
7375

74-
apt := "apt-get -o APT::Sandbox::User=root install -y -qq "
75-
tips := map[string]string{}
76-
for _, pkg := range []string{"tree", "jq", "figlet"} {
77-
c.call(map[string]any{"op": "checkout", "node": base})
78-
c.call(map[string]any{"op": "exec", "cmd": apt + pkg})
79-
tips[pkg] = c.call(map[string]any{"op": "head"}).Head
80-
fmt.Printf(" explored %s -> %s\n", pkg, tips[pkg])
76+
fmt.Println("tournament results:")
77+
for _, b := range out.Branches {
78+
verdict := fmt.Sprintf("exit %d", b.TestExit)
79+
if b.TestExit == 0 {
80+
verdict = "PASS"
81+
}
82+
fmt.Printf(" %s %s %s\n", b.Name, b.Node, verdict)
83+
}
84+
if out.Winner == "" {
85+
fmt.Println("no candidate passed the test")
86+
os.Exit(1)
8187
}
88+
fmt.Printf("winner: %s (node %s) — HEAD now %s\n", out.Winner, out.WinnerNode, out.Head)
8289

83-
winner := ""
84-
for pkg, tip := range tips {
85-
c.call(map[string]any{"op": "checkout", "node": tip})
86-
if *c.call(map[string]any{"op": "exec", "cmd": "command -v jq >/dev/null"}).Exit == 0 {
87-
winner = pkg
90+
// Optional clean-up: prune the losing branches with the v0.2.0 `delete` op.
91+
// Children re-parent to the deleted node's parent; harmless here (losers
92+
// are leaves) and keeps the DAG free of dead-end snapshots.
93+
for _, b := range out.Branches {
94+
if b.Name != out.Winner {
95+
call(c, r, map[string]any{"op": "delete", "node": b.Node})
96+
fmt.Printf(" pruned %s (node %s)\n", b.Name, b.Node)
8897
}
8998
}
90-
fmt.Println("winner:", winner)
91-
c.call(map[string]any{"op": "checkout", "node": tips[winner]})
9299
}

examples/mcp_client.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#!/usr/bin/env python3
2+
"""Drive `agentenv mcp` as a generic MCP client (no Claude Code needed).
3+
4+
The MCP server (added in v0.1.0) is what lets Claude Code roll back its own
5+
environment via the agentenv__checkout / agentenv__delete tools. This file
6+
shows the same surface is reachable from any language that can fork a process
7+
and exchange newline-delimited JSON-RPC over stdio — useful as a smoke test, a
8+
CI integration, or the foundation for another agent harness.
9+
10+
Walks through every v0.2.0 tool: log, head, branches, show, diff, checkout,
11+
delete (the new one — prunes a node from the DAG). Requires `agentenv mcp` on
12+
PATH and a running daemon (or pass --socket to a known control socket).
13+
14+
python3 mcp_client.py [--socket /var/lib/agentenv/agentenv.sock]
15+
"""
16+
import json
17+
import os
18+
import subprocess
19+
import sys
20+
21+
22+
class MCP:
23+
"""Minimal MCP client: subprocess agentenv mcp, framed JSON-RPC on stdio."""
24+
25+
def __init__(self, socket=None):
26+
env = os.environ.copy()
27+
if socket:
28+
env["AGENTENV_SOCKET"] = socket
29+
self.p = subprocess.Popen(
30+
["agentenv", "mcp"],
31+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
32+
env=env, text=True, bufsize=1,
33+
)
34+
self.id = 0
35+
self._req("initialize", {
36+
"protocolVersion": "2024-11-05",
37+
"capabilities": {},
38+
"clientInfo": {"name": "mcp-client-py", "version": "0"},
39+
})
40+
# An MCP host normally sends "initialized" as a notification; the
41+
# agentenv server tolerates skipping it for one-shot drivers like this.
42+
43+
def _req(self, method, params):
44+
self.id += 1
45+
self.p.stdin.write(json.dumps({
46+
"jsonrpc": "2.0", "id": self.id, "method": method, "params": params,
47+
}) + "\n")
48+
self.p.stdin.flush()
49+
# Drain frames until we see our own id (server may emit notifications).
50+
while True:
51+
line = self.p.stdout.readline()
52+
if not line:
53+
raise RuntimeError("mcp server closed stdout: " + self.p.stderr.read())
54+
msg = json.loads(line)
55+
if msg.get("id") == self.id:
56+
if "error" in msg:
57+
raise RuntimeError(f"{method}: {msg['error']}")
58+
return msg["result"]
59+
60+
def tool(self, name, **args):
61+
"""Invoke an agentenv__* MCP tool, return the joined text content."""
62+
r = self._req("tools/call", {"name": name, "arguments": args})
63+
if r.get("isError"):
64+
raise RuntimeError(f"{name} returned isError: {r}")
65+
return "".join(c.get("text", "") for c in r.get("content", []))
66+
67+
def close(self):
68+
try:
69+
self.p.stdin.close()
70+
finally:
71+
self.p.wait(timeout=5)
72+
73+
74+
def main():
75+
socket = None
76+
if "--socket" in sys.argv:
77+
socket = sys.argv[sys.argv.index("--socket") + 1]
78+
mcp = MCP(socket=socket)
79+
try:
80+
print("HEAD:", mcp.tool("agentenv__head").strip())
81+
print("--- log ---")
82+
log = mcp.tool("agentenv__log")
83+
print(log.rstrip())
84+
85+
# Pick any non-HEAD, non-root leaf node to prune as a demo. Parsing the
86+
# log text is brittle (it's human-readable) — a real driver would call
87+
# agentenv__branches and pick from there.
88+
leaves = [
89+
line.split()[0] for line in mcp.tool("agentenv__branches").splitlines()
90+
if line.strip() and "init from" not in line and "<- HEAD" not in line
91+
]
92+
if leaves:
93+
target = leaves[0]
94+
print(f"--- delete leaf {target} (v0.2.0 op) ---")
95+
print(mcp.tool("agentenv__delete", node=target).rstrip())
96+
print("--- log after delete ---")
97+
print(mcp.tool("agentenv__log").rstrip())
98+
else:
99+
print("(no non-HEAD leaves to prune — skipping delete demo)")
100+
finally:
101+
mcp.close()
102+
103+
104+
if __name__ == "__main__":
105+
main()

0 commit comments

Comments
 (0)