Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
name: judge-parent
description: >-
Parent agent that delegates evaluation to a subagent judge.
The subagent call is intercepted by a PreToolUse hook and
executed as a separate claude process for isolation.
model: sonnet
---

You are an agent testing subagent process isolation.

All output files MUST be written under the directory specified by the
FULLSEND_OUTPUT_DIR environment variable. Read it with:
```bash
echo $FULLSEND_OUTPUT_DIR
```

Your task:

1. Read FULLSEND_OUTPUT_DIR and create the directory if needed
2. Write a short paragraph (3-4 sentences) about the Python
programming language to `$FULLSEND_OUTPUT_DIR/topic.md`
3. Use the Agent tool to spawn a subagent that evaluates the
paragraph you wrote. The subagent prompt must include the
absolute path to the file, e.g.:
"Read the file /sandbox/workspace/output/topic.md and evaluate
the paragraph on a scale of 1-5 for accuracy, clarity, and
completeness. Return a structured evaluation with scores and
brief justification for each."
4. Write the evaluation result to `$FULLSEND_OUTPUT_DIR/evaluation.md`
5. Write a one-line summary of whether the evaluation was
positive or negative to `$FULLSEND_OUTPUT_DIR/summary.md`
2 changes: 2 additions & 0 deletions 0025-subagent-process-isolation/.fullsend/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
agents:
- source: harness/judge-parent.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Agent",
"hooks": [
{
"type": "command",
"command": "python3 /sandbox/workspace/.fullsend-hooks/pretooluse.py",
"timeout": 300
}
]
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export CLAUDE_CODE_USE_VERTEX=1
export ANTHROPIC_VERTEX_PROJECT_ID=${ANTHROPIC_VERTEX_PROJECT_ID}
export CLOUD_ML_REGION=${CLOUD_ML_REGION}
export GOOGLE_APPLICATION_CREDENTIALS=/tmp/.gcp-credentials.json
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
export GOOGLE_CLOUD_PROJECT=${GOOGLE_CLOUD_PROJECT}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
agent: agents/judge-parent.md
role: experiment
model: sonnet
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
policy: https://raw.githubusercontent.com/fullsend-ai/agents/3760b3bb70de32596c1922332fb96e76cd9ebcd8/policies/triage.yaml#sha256=896a5f89b8e58ea78e64641dc8f6261eb98383bb4bfec949f4b98755c957ef77

allowed_remote_resources:
- "https://raw.githubusercontent.com/fullsend-ai/agents/"
allow_runtime_fetch: true

host_files:
- src: env/gcp-vertex.env
dest: /sandbox/workspace/.env.d/gcp-vertex.env
expand: true
- src: ${GOOGLE_APPLICATION_CREDENTIALS}
Comment thread
maruiz93 marked this conversation as resolved.
dest: /tmp/.gcp-credentials.json
Comment thread
maruiz93 marked this conversation as resolved.
- src: hooks/pretooluse.py
dest: /sandbox/workspace/.fullsend-hooks/pretooluse.py
- src: config/claude-hooks.json
dest: /sandbox/claude-config/settings.json

validation_loop:
script: scripts/validate-output.sh
max_iterations: 1

post_script: scripts/post-emit-cost.py

timeout_minutes: 10
145 changes: 145 additions & 0 deletions 0025-subagent-process-isolation/.fullsend/hooks/pretooluse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""PreToolUse hook: intercepts Agent() calls, spawns isolated claude process,
Comment thread
maruiz93 marked this conversation as resolved.
feeds the result back through the subagent as a pass-through echo."""

import json
import os
import signal
import shutil
import subprocess
import sys
import time


def _save_spawned_cost(pid, result_json, start_ns, end_ns):
"""Write spawned process cost data to FULLSEND_OUTPUT_DIR for post_script."""
output_dir = os.environ.get("FULLSEND_OUTPUT_DIR")
if not output_dir:
print(f"[intercept] pid={pid} WARNING: FULLSEND_OUTPUT_DIR not set, skipping cost save", file=sys.stderr)
return

usage = result_json.get("usage", {})
model_usage = result_json.get("modelUsage", {})
model = next(iter(model_usage), "") if model_usage else ""

cost_data = {
"total_cost_usd": result_json.get("total_cost_usd", 0),
"session_id": result_json.get("session_id", ""),
"num_turns": result_json.get("num_turns", 0),
"duration_ms": result_json.get("duration_ms", 0),
"model": model,
"start_time_unix_nano": str(start_ns),
"end_time_unix_nano": str(end_ns),
"usage": {
"input_tokens": usage.get("input_tokens", 0),
"output_tokens": usage.get("output_tokens", 0),
"cache_creation_input_tokens": usage.get("cache_creation_input_tokens", 0),
"cache_read_input_tokens": usage.get("cache_read_input_tokens", 0),
},
}

cost_path = os.path.join(output_dir, ".spawned-cost.json")
os.makedirs(output_dir, exist_ok=True)
with open(cost_path, "w") as f:
json.dump(cost_data, f, indent=2)
print(f"[intercept] pid={pid} saved cost data to {cost_path}: ${cost_data['total_cost_usd']:.4f}", file=sys.stderr)


def main():
if not os.path.isdir("/sandbox/workspace"):
Comment thread
maruiz93 marked this conversation as resolved.
Comment thread
maruiz93 marked this conversation as resolved.
print("[intercept] refusing to run outside sandbox", file=sys.stderr)
return

if os.environ.get("FULLSEND_HOOK_SPAWNED"):
print("[intercept] skipping: spawned subprocess", file=sys.stderr)
return

try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError, ValueError):
print("[intercept] skipping: malformed or empty stdin", file=sys.stderr)
return
tool_name = hook_input.get("tool_name", "")

if tool_name != "Agent":
print(f"[intercept] skipping: tool={tool_name}", file=sys.stderr)
return

tool_input = hook_input.get("tool_input", {})
original_prompt = tool_input.get("prompt", "")

if not original_prompt:
print("[intercept] skipping: empty prompt", file=sys.stderr)
return

claude_bin = os.environ.get("CLAUDE_BIN") or shutil.which("claude") or "claude"
pid = os.getpid()

print(f"[intercept] pid={pid} Intercepting Agent call, spawning isolated process...", file=sys.stderr)
print(f"[intercept] pid={pid} claude_bin={claude_bin}", file=sys.stderr)
print(f"[intercept] pid={pid} original_prompt={original_prompt[:120]}", file=sys.stderr)

env = os.environ.copy()
Comment thread
maruiz93 marked this conversation as resolved.
Comment thread
maruiz93 marked this conversation as resolved.
env["FULLSEND_HOOK_SPAWNED"] = "1"

start_ns = int(time.time() * 1e9)

try:
proc = subprocess.Popen(
[claude_bin, "-p", "--output-format", "json", "--dangerously-skip-permissions"],
Comment thread
maruiz93 marked this conversation as resolved.
Comment thread
maruiz93 marked this conversation as resolved.
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
start_new_session=True,
)
try:
stdout, stderr = proc.communicate(input=original_prompt, timeout=270)
Comment thread
maruiz93 marked this conversation as resolved.
Comment thread
maruiz93 marked this conversation as resolved.
except subprocess.TimeoutExpired:
try:
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, OSError):
pass
proc.wait()
agent_output = "ERROR: claude process timed out after 270s"
print(f"[intercept] pid={pid} TIMEOUT — killed process group {proc.pid}", file=sys.stderr)
else:
end_ns = int(time.time() * 1e9)
print(f"[intercept] pid={pid} spawned_process_returncode={proc.returncode}", file=sys.stderr)

try:
result_json = json.loads(stdout)
agent_output = result_json.get("result", "").strip()
_save_spawned_cost(pid, result_json, start_ns, end_ns)
except (json.JSONDecodeError, ValueError):
agent_output = stdout.strip()
print(f"[intercept] pid={pid} WARNING: could not parse JSON output, using raw text", file=sys.stderr)

print(f"[intercept] pid={pid} result_length={len(agent_output)} chars", file=sys.stderr)

if proc.returncode != 0 and not agent_output:
Comment thread
maruiz93 marked this conversation as resolved.
agent_output = f"Process exited {proc.returncode}. stderr: {stderr[:500]}"
except OSError as e:
agent_output = f"ERROR: could not start claude CLI at {claude_bin}: {e}"
print(f"[intercept] pid={pid} SUBPROCESS_ERROR at {claude_bin}: {e}", file=sys.stderr)

Comment thread
maruiz93 marked this conversation as resolved.
updated_input = dict(tool_input)
updated_input["prompt"] = (
"An external process has already completed this task. "
"Return the following result exactly as-is, with no additions, "
"modifications, or commentary:\n\n"
f"{agent_output}"
Comment thread
maruiz93 marked this conversation as resolved.
)

response = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": updated_input,
},
}
json.dump(response, sys.stdout)


if __name__ == "__main__":
main()
129 changes: 129 additions & 0 deletions 0025-subagent-process-isolation/.fullsend/scripts/post-emit-cost.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Post-script: emits an OTEL span for the spawned subprocess's cost.

Reads .spawned-cost.json from the output directory (written by the
pretooluse hook), then sends a single span to the OTLP endpoint under
the same trace as the parent run. This makes the spawned process's
cost visible in MLflow alongside the parent's cost.

Environment (set by fullsend):
TRACEPARENT — W3C traceparent from the parent run
OTEL_EXPORTER_OTLP_ENDPOINT — OTLP HTTP endpoint (e.g. http://host:4318)
CWD — the run directory (contains iteration-*/output/)
"""

import glob
import json
import os
import struct
import sys
import time
import urllib.request
import urllib.error


def parse_traceparent(tp):
"""Parse W3C traceparent: 00-{trace_id}-{parent_span_id}-{flags}."""
parts = tp.split("-")
if len(parts) != 4 or parts[0] != "00":
return None
return {"trace_id": parts[1], "parent_span_id": parts[2], "flags": int(parts[3], 16)}


def new_span_id():
"""Generate a random 16-hex-char span ID."""
return struct.pack(">Q", int.from_bytes(os.urandom(8), "big")).hex()


def build_otlp_payload(trace_id, parent_span_id, cost_data):
"""Build OTLP/JSON ExportTraceServiceRequest with one span."""
start_ns = cost_data.get("start_time_unix_nano", str(int(time.time() * 1e9)))
end_ns = cost_data.get("end_time_unix_nano", start_ns)

usage = cost_data.get("usage", {})
attributes = [
{"key": "fullsend.cost_usd", "value": {"doubleValue": cost_data.get("total_cost_usd", 0)}},
{"key": "fullsend.spawned_session_id", "value": {"stringValue": cost_data.get("session_id", "")}},
{"key": "gen_ai.request.model", "value": {"stringValue": cost_data.get("model", "")}},
{"key": "gen_ai.usage.input_tokens", "value": {"intValue": str(usage.get("input_tokens", 0))}},
{"key": "gen_ai.usage.output_tokens", "value": {"intValue": str(usage.get("output_tokens", 0))}},
{"key": "gen_ai.usage.cache_creation.input_tokens", "value": {"intValue": str(usage.get("cache_creation_input_tokens", 0))}},
{"key": "gen_ai.usage.cache_read.input_tokens", "value": {"intValue": str(usage.get("cache_read_input_tokens", 0))}},
{"key": "fullsend.num_turns", "value": {"intValue": str(cost_data.get("num_turns", 0))}},
]

return {
"resourceSpans": [{
"resource": {
"attributes": [
{"key": "service.name", "value": {"stringValue": "fullsend"}},
],
},
"scopeSpans": [{
"scope": {"name": "fullsend.post-script"},
"spans": [{
"traceId": trace_id,
"spanId": new_span_id(),
"parentSpanId": parent_span_id,
"name": "spawned_agent",
"kind": 1,
"startTimeUnixNano": start_ns,
"endTimeUnixNano": end_ns,
"attributes": attributes,
"status": {"code": 1},
}],
}],
}],
}


def main():
traceparent = os.environ.get("TRACEPARENT", "")
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "")

if not traceparent:
print("[post-emit-cost] no TRACEPARENT, skipping", file=sys.stderr)
return

if not endpoint:
print("[post-emit-cost] no OTEL_EXPORTER_OTLP_ENDPOINT, skipping", file=sys.stderr)
return

tp = parse_traceparent(traceparent)
if not tp:
print(f"[post-emit-cost] invalid TRACEPARENT: {traceparent}", file=sys.stderr)
return

cost_files = glob.glob("iteration-*/output/.spawned-cost.json")
if not cost_files:
print("[post-emit-cost] no .spawned-cost.json found, skipping", file=sys.stderr)
return

for cost_file in cost_files:
with open(cost_file) as f:
cost_data = json.load(f)

cost_usd = cost_data.get("total_cost_usd", 0)
print(f"[post-emit-cost] found {cost_file}: ${cost_usd:.4f}", file=sys.stderr)

payload = build_otlp_payload(tp["trace_id"], tp["parent_span_id"], cost_data)
url = endpoint.rstrip("/") + "/v1/traces"

req = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)

try:
with urllib.request.urlopen(req, timeout=10) as resp:
print(f"[post-emit-cost] sent span to {url}: {resp.status}", file=sys.stderr)
except urllib.error.URLError as e:
print(f"[post-emit-cost] failed to send span to {url}: {e}", file=sys.stderr)
except Exception as e:
print(f"[post-emit-cost] unexpected error: {e}", file=sys.stderr)


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail

output_dir="${FULLSEND_OUTPUT_DIR:-./output}"
failures=0

for file in topic.md evaluation.md summary.md; do
if [[ ! -f "$output_dir/$file" ]]; then
echo "FAIL: $output_dir/$file not found"
failures=$((failures + 1))
else
echo "PASS: $output_dir/$file exists ($(wc -c < "$output_dir/$file") bytes)"
fi
done

if [[ $failures -gt 0 ]]; then
echo "$failures file(s) missing"
exit 1
fi

echo "All output files present"
2 changes: 2 additions & 0 deletions 0025-subagent-process-isolation/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
results/
.fullsend/.fullsend-cache/
Loading
Loading