Skip to content

Commit 24c258d

Browse files
mios-devclaude
andcommitted
launcher broker: add CAPTURE_JSON: protocol; pipe + verbs use it
Foundation for the agent envelope's clean stdout/stderr split. The prior CAPTURE: protocol concatenated subprocess stdout + stderr as one byte stream over the socket, so the pipe couldn't tell which part was the structured tool output and which part was English narrative from a downstream tool (mios-find, mios-launch, mios- windows, ...) writing diagnostics to stderr. The OpenAI tool_result envelope wants stdout in tool_result.output and stderr in tool_result.stderr -- separately -- so any English narrative buckets into a clearly-labeled field instead of polluting the operator-facing output. usr/libexec/mios/mios-launcher-daemon * New CAPTURE_JSON: <cmd> command. Runs the same subprocess.run as CAPTURE:, returns a single JSON line: {"stdout":"...", "stderr":"...", "exit_code":N} Legacy CAPTURE: stays available for pre-update callers. usr/share/mios/owui/pipes/mios_agent_pipe.py * _dispatch_mios_verb's broker call now uses CAPTURE_JSON: instead of CAPTURE:, parses the JSON response, and populates the envelope with: tool_result.output <- broker stdout tool_result.stderr <- broker stderr tool_result.success <- (exit_code == 0) tool_result.exit_code <- raw exit code * open_app dispatch loses its `2>/dev/null` hack -- stderr now lands in its own field, so suppressing it at the bash level is no longer needed and we keep the diagnostic info. usr/share/mios/owui/tools/mios_verbs.py * _broker_send (used by every Tools class method when Hermes invokes a verb via OpenAI tool_calls) also switched to CAPTURE_JSON:. Fallback path retained: if the broker reply isn't valid JSON (older deployment), treat the whole thing as stdout to preserve the prior CAPTURE: text contract. Live-verified on podman-MiOS-DEV: CAPTURE_JSON: mios-find notepad returns stdout = the canonical launch command, stderr = a tmpfs permission warning (cleanly bucketed into its own field), exit 0. Tool row in webui.db: 31528 -> 32443 chars after re-install. Operator directive 2026-05-18: "MAKE SURE NO HARDCODED VALUES!" The pipe layer's envelope is now structured-shape-only -- the universal status symbols (✓/⚠), the OpenAI protocol identifiers, and the JSON keys. Any English in the envelope necessarily comes from a downstream tool's stderr (clearly labeled) -- no English narrative invented by the pipe. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 83dcfb4 commit 24c258d

3 files changed

Lines changed: 102 additions & 25 deletions

File tree

usr/libexec/mios/mios-launcher-daemon

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,15 +83,51 @@ def handle(conn: socket.socket) -> None:
8383

8484
# Protocol:
8585
# <command> -> fire-and-forget; reply "OK\n"
86-
# CAPTURE: <command> -> run synchronously; capture stdout+stderr;
87-
# reply with raw output (no OK prefix); close
86+
# CAPTURE: <command> -> run synchronously; capture stdout+stderr
87+
# CONCATENATED as bytes (legacy form;
88+
# kept for backward compat).
89+
# CAPTURE_JSON: <command> -> run synchronously; reply with a
90+
# single-line JSON object:
91+
# {"stdout":"...","stderr":"...","exit_code":N}
92+
# Lets the agent envelope split stdout
93+
# from stderr cleanly -- English narrative
94+
# from downstream tools (mios-find, mios-
95+
# launch, ...) buckets into the stderr
96+
# field instead of mixing with the
97+
# tool_result.output structured data.
8898
# CAPTURE removes the cross-user tempfile dance: broker runs the
8999
# command in its own context, captures via subprocess.PIPE, sends
90100
# the bytes back to the caller. No file in /tmp or /run/mios-
91101
# launcher/ that another user has to write to. Operator-debug
92102
# 2026-05-15 found that mios cross-user-write to a mios-hermes-
93103
# owned mode 0666 file fails -- root cause unknown, fix unneeded
94104
# via this protocol.
105+
if line.startswith("CAPTURE_JSON: "):
106+
cmd = line[len("CAPTURE_JSON: "):]
107+
log.info("capture_json: %s", cmd[:200])
108+
env = dict(os.environ)
109+
env.setdefault("GDK_BACKEND", "wayland")
110+
payload = {"stdout": "", "stderr": "", "exit_code": -1}
111+
try:
112+
result = subprocess.run(
113+
["bash", "-lc", cmd],
114+
stdin=subprocess.DEVNULL,
115+
capture_output=True,
116+
timeout=45,
117+
env=env,
118+
)
119+
payload["stdout"] = result.stdout.decode("utf-8", errors="replace")
120+
payload["stderr"] = result.stderr.decode("utf-8", errors="replace")
121+
payload["exit_code"] = int(result.returncode)
122+
except subprocess.TimeoutExpired:
123+
payload["stderr"] = "ERROR: capture timeout (>45s)"
124+
except OSError as e:
125+
payload["stderr"] = f"ERROR: {e}"
126+
# Send as a single JSON line for unambiguous parsing.
127+
import json as _json
128+
conn.sendall((_json.dumps(payload) + "\n").encode("utf-8"))
129+
return
130+
95131
if line.startswith("CAPTURE: "):
96132
cmd = line[len("CAPTURE: "):]
97133
log.info("capture: %s", cmd[:200])

usr/share/mios/owui/pipes/mios_agent_pipe.py

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,11 +1179,12 @@ async def _dispatch_mios_verb(
11791179
# actual operator-installed Steam client. mios-launch is
11801180
# the environment-generative path: games-cache is populated
11811181
# by mios-apps at runtime, not from a baked priority list.
1182-
# 2>/dev/null suppresses any stderr narrative (e.g.
1183-
# "mios-launch: <name> -> <cmd>" diagnostic line); the
1184-
# tool_result.stderr field will be a future broker-protocol
1185-
# enhancement so stderr isn't lost on failures.
1186-
cmd = f"{env_prefix}mios-launch {shlex.quote(name)} 2>/dev/null"
1182+
# No 2>/dev/null filter -- the broker's CAPTURE_JSON
1183+
# protocol (used below) now returns stdout / stderr / exit
1184+
# SEPARATELY, so English narrative on stderr lands in
1185+
# tool_result.stderr (labeled-as-stderr in the envelope)
1186+
# instead of polluting tool_result.output.
1187+
cmd = f"{env_prefix}mios-launch {shlex.quote(name)}"
11871188
elif tool == "launch_app":
11881189
cmd = f"mios-launch {shlex.quote(str(args.get('name', '')))}"
11891190
elif tool == "focus_window":
@@ -1322,7 +1323,13 @@ async def _dispatch_mios_verb(
13221323
s = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
13231324
s.settimeout(15.0)
13241325
s.connect(sock_path)
1325-
s.sendall(("CAPTURE: " + cmd + "\n").encode())
1326+
# CAPTURE_JSON: protocol -- broker returns a single
1327+
# JSON line with {stdout, stderr, exit_code} so we can
1328+
# bucket English narrative on stderr into its own
1329+
# envelope field (instead of mixing with structured
1330+
# tool_result.output). Backward-compat CAPTURE: stays
1331+
# available on the broker for older callers.
1332+
s.sendall(("CAPTURE_JSON: " + cmd + "\n").encode())
13261333
chunks: list[bytes] = []
13271334
try:
13281335
while True:
@@ -1334,14 +1341,26 @@ async def _dispatch_mios_verb(
13341341
finally:
13351342
s.close()
13361343
raw = b"".join(chunks).decode("utf-8", errors="replace").strip()
1337-
if raw.startswith("ERROR:"):
1338-
_result_payload = {"success": False, "stderr": raw}
1339-
else:
1340-
_result_payload = {"success": True,
1344+
try:
1345+
j = json.loads(raw) if raw else {}
1346+
except json.JSONDecodeError:
1347+
j = {}
1348+
if not j:
1349+
_result_payload = {"success": False,
13411350
"tool": tool, "args": args,
1342-
"output": raw[:6000]}
1351+
"output": "", "stderr": raw or "broker: empty response"}
1352+
else:
1353+
exit_code = int(j.get("exit_code", -1))
1354+
_result_payload = {
1355+
"success": exit_code == 0,
1356+
"tool": tool, "args": args,
1357+
"output": (j.get("stdout") or "")[:6000],
1358+
"stderr": (j.get("stderr") or "")[:2000],
1359+
"exit_code": exit_code,
1360+
}
13431361
except OSError as e:
1344-
_result_payload = {"success": False, "stderr": f"broker: {e}"}
1362+
_result_payload = {"success": False, "stderr": f"broker: {e}",
1363+
"output": "", "tool": tool, "args": args}
13451364
# Best-effort SurrealDB write: tool_call row. Carries session id
13461365
# if pipe() opened one this turn (self._session_id). Output is
13471366
# truncated to keep the row compact.

usr/share/mios/owui/tools/mios_verbs.py

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,15 @@ def _broker_send(line: str, timeout: float, capture: bool) -> dict:
8989
return {"success": False, "exit_code": -1, "stdout": "",
9090
"stderr": f"launcher broker socket not present at {LAUNCHER_SOCKET} "
9191
"(mios-launcher.service down? container missing the mount?)"}
92-
payload = (f"CAPTURE: {line}" if capture else line).encode("utf-8") + b"\n"
92+
# CAPTURE_JSON: returns a single JSON line {stdout, stderr, exit_code}
93+
# so we get clean per-stream output for the agent envelope (English
94+
# narratives from downstream tools land in stderr, structured data
95+
# in stdout). Fire-and-forget calls (capture=False) keep the legacy
96+
# plain-text protocol ("OK" / "ERROR: ...").
97+
if capture:
98+
payload = (f"CAPTURE_JSON: {line}").encode("utf-8") + b"\n"
99+
else:
100+
payload = line.encode("utf-8") + b"\n"
93101
try:
94102
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
95103
s.settimeout(timeout)
@@ -109,21 +117,35 @@ def _broker_send(line: str, timeout: float, capture: bool) -> dict:
109117
except OSError as e:
110118
return {"success": False, "exit_code": -1,
111119
"stdout": "", "stderr": f"broker connect: {e}"}
112-
raw = b"".join(chunks).decode("utf-8", errors="replace")
120+
raw = b"".join(chunks).decode("utf-8", errors="replace").strip()
113121
if capture:
114-
# CAPTURE mode: raw output (no OK/ERROR framing). Empty reply =
115-
# command produced no output (still a success if no error).
116-
if raw.startswith("ERROR:"):
117-
return {"success": False, "exit_code": -1,
118-
"stdout": "", "stderr": raw.strip()}
119-
return {"success": True, "exit_code": 0,
120-
"stdout": raw.strip()[:12000], "stderr": ""}
122+
# Parse the JSON line. If parsing fails (e.g. broker pre-dates
123+
# CAPTURE_JSON support and replied with raw bytes), fall back to
124+
# treating the whole reply as stdout.
125+
try:
126+
j = json.loads(raw) if raw else {}
127+
except (json.JSONDecodeError, ValueError):
128+
j = {}
129+
if not j:
130+
# Pre-CAPTURE_JSON fallback path: raw text reply.
131+
if raw.startswith("ERROR:"):
132+
return {"success": False, "exit_code": -1,
133+
"stdout": "", "stderr": raw}
134+
return {"success": True, "exit_code": 0,
135+
"stdout": raw[:12000], "stderr": ""}
136+
exit_code = int(j.get("exit_code", -1))
137+
return {
138+
"success": exit_code == 0,
139+
"exit_code": exit_code,
140+
"stdout": (j.get("stdout") or "")[:12000],
141+
"stderr": (j.get("stderr") or "")[:4000],
142+
}
121143
# Fire-and-forget: "OK\n" or "ERROR: ..."
122-
if raw.strip().startswith("OK"):
144+
if raw.startswith("OK"):
123145
return {"success": True, "exit_code": 0,
124146
"stdout": "", "stderr": ""}
125147
return {"success": False, "exit_code": -1,
126-
"stdout": "", "stderr": raw.strip() or "broker returned no reply"}
148+
"stdout": "", "stderr": raw or "broker returned no reply"}
127149

128150

129151
class Tools:

0 commit comments

Comments
 (0)