Files
hermes-agent/tests/tools/test_completed_process_results.py

288 lines
14 KiB
Python

"""Completed work remains retrievable when its finite CLI owner exits."""
from collections import Counter
import http.server
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
import textwrap
import threading
import time
REPO_ROOT = Path(__file__).resolve().parents[2]
def _new_background_notifications(messages, seen_counts):
"""Count new history occurrences, not positions shifted by request assembly."""
counts = Counter(
m["content"] for m in messages
if m["role"] == "user"
and isinstance(m.get("content"), str)
and m["content"].startswith("[IMPORTANT: Background process ")
)
new = []
for content, count in counts.items():
new.extend([content] * max(0, count - seen_counts[content]))
seen_counts[content] = max(seen_counts[content], count)
return new
def test_background_notification_history_replay_is_not_new_delivery():
notice = "[IMPORTANT: Background process proc_a exited (exit code 7).]"
seen_counts = Counter()
first = [{"role": "system", "content": "first"}, {"role": "user", "content": notice}]
shifted = [{"role": "system", "content": "second"}, {"role": "user", "content": "query"}, *first[1:]]
assert _new_background_notifications(first, seen_counts) == [notice]
assert _new_background_notifications(shifted, seen_counts) == []
assert _new_background_notifications([*shifted, first[1]], seen_counts) == [notice]
def test_headless_terminal_result_survives_cli_exit(tmp_path):
"""Real CLI, tool dispatch, shell child and fresh reader; only the LLM is local."""
home = tmp_path / "profile"
home.mkdir()
(home / "config.yaml").write_text(
"model:\n provider: custom\n api_mode: chat_completions\n"
"terminal:\n env: local\n oneshot_completion_wait_seconds: 10\n"
"memory:\n memory_enabled: false\n user_profile_enabled: false\n",
encoding="utf-8",
)
release = tmp_path / "release"
child = tmp_path / "review.py"
child.write_text(textwrap.dedent('''
import pathlib, sys, time
deadline = time.monotonic() + 15
while not pathlib.Path(sys.argv[1]).exists():
if time.monotonic() > deadline:
sys.exit(91)
time.sleep(0.02)
print("SYNTHETIC_REVIEW_COMPLETE")
print("review stderr", file=sys.stderr)
sys.exit(7)
'''), encoding="utf-8")
# The local terminal backend uses bash, including Git Bash on Windows.
command = shlex.join(path.as_posix() for path in (Path(sys.executable), child, release))
observed = []
seen_tool = set()
seen_follow_up_counts = Counter()
follow_ups = []
completed_during_provider_reply = threading.Event()
class Provider(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_error(404)
def do_POST(self):
request = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
if "messages" not in request:
self.send_error(404)
return
tool_results = [m for m in request["messages"] if m["role"] == "tool"]
follow_ups.extend(_new_background_notifications(request["messages"], seen_follow_up_counts))
has_terminal = any(t.get("function", {}).get("name") == "terminal"
for t in request.get("tools", []))
message = {"role": "assistant", "content": "Coordinator finished."}
if has_terminal and not tool_results:
message.update(content=None, tool_calls=[{
"id": "call_review", "type": "function", "function": {
"name": "terminal", "arguments": json.dumps({
"command": command, "background": True, "notify": True,
}),
},
}])
elif tool_results:
# Requests accumulate history, so a follow-up turn re-carries the start
# receipt: count each distinct tool message once.
for m in tool_results:
key = json.dumps(m, sort_keys=True)
if key not in seen_tool:
seen_tool.add(key)
observed.append(json.loads(m["content"]))
release.touch()
# Hold the tool-result response until the real child has exited: on a
# loaded runner completion can race this provider request.
deadline = time.monotonic() + 25
receipt = home / "logs" / "process-results" / f"{observed[0]['session_id']}.json"
while not receipt.is_file() and time.monotonic() < deadline:
time.sleep(0.01)
if receipt.is_file():
completed_during_provider_reply.set()
response = {
"id": "chatcmpl-local", "object": "chat.completion", "created": 1,
"model": "test-model", "choices": [{
"index": 0, "message": message,
"finish_reason": "tool_calls" if "tool_calls" in message else "stop",
}], "usage": {"prompt_tokens": 10, "completion_tokens": 10, "total_tokens": 20},
}
content_type = "application/json"
if request.get("stream"):
response["object"] = "chat.completion.chunk"
response["choices"][0]["delta"] = response["choices"][0].pop("message")
for index, tool in enumerate(message.get("tool_calls", [])):
tool["index"] = index
raw = ("data: " + json.dumps(response) + "\n\ndata: [DONE]\n\n").encode()
content_type = "text/event-stream"
else:
raw = json.dumps(response).encode()
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def log_message(self, *args):
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Provider)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
url = f"http://127.0.0.1:{server.server_port}/v1"
env = {**os.environ, "HERMES_HOME": str(home), "HOME": str(tmp_path),
"USERPROFILE": str(tmp_path), "TERMINAL_CWD": str(tmp_path),
"OPENAI_BASE_URL": url, "OPENAI_API_KEY": "local-test-only",
"PYTHONPATH": str(REPO_ROOT)}
try:
producer = subprocess.run([
sys.executable, "-c",
"import cli; cli.main(query='Run the background review', quiet=True, "
"oneshot=True, provider='custom', model='test-model', api_key='local-test-only', "
f"base_url={url!r}, toolsets='terminal', max_turns=3, ignore_rules=True)",
], cwd=tmp_path, env=env, stdin=subprocess.DEVNULL,
capture_output=True, text=True, encoding="utf-8", timeout=60)
finally:
release.touch()
server.shutdown()
server.server_close()
thread.join(timeout=5)
assert producer.returncode == 0, producer.stdout + producer.stderr
assert completed_during_provider_reply.is_set(), producer.stdout + producer.stderr
assert "Coordinator finished." in producer.stdout
assert len(observed) == 1, (observed, producer.stdout, producer.stderr)
process_id = observed[0]["session_id"]
assert observed[0].get("notify_on_complete") is True, observed
# A terminal completion is an [IMPORTANT: Background process ...] event,
# not an [ASYNC DELEGATION ...] event. The same history entry can appear in
# several provider requests without being delivered a second time.
assert len(follow_ups) == 1, follow_ups
assert follow_ups[0].startswith(f"[IMPORTANT: Background process {process_id} exited (exit code 7).")
assert "SYNTHETIC_REVIEW_COMPLETE" in follow_ups[0]
consumer = textwrap.dedent('''
import json, sys
import tools.process_registry as pr
from tools.registry import registry
result = registry.get_entry("process_manage").handler(
{"action": "log", "session_id": sys.argv[1]})
status = registry.get_entry("process_manage").handler(
{"action": "poll", "session_id": sys.argv[1]})
print(json.dumps({"result": json.loads(result), "status": json.loads(status),
"replayed": not pr.process_registry.completion_queue.empty()}))
''')
def read_result(profile):
result = subprocess.run([sys.executable, "-c", consumer, process_id],
cwd=tmp_path, env={**env, "HERMES_HOME": str(profile)},
check=True, stdin=subprocess.DEVNULL, capture_output=True,
text=True, encoding="utf-8", timeout=30)
return json.loads(result.stdout)
receipt = json.loads((home / "logs" / "process-results" / f"{process_id}.json").read_text(encoding="utf-8"))
assert receipt["parent_session_id"] # CLI owner must be stamped before its reader starts.
env["HERMES_SESSION_ID"] = receipt["parent_session_id"]
recovered = read_result(home)
assert recovered["result"]["status"] == "exited", recovered
assert recovered["status"]["exit_code"] == 7, recovered
assert "SYNTHETIC_REVIEW_COMPLETE" in recovered["result"]["output"]
assert "review stderr" in recovered["result"]["output"]
assert recovered["replayed"] is False
assert read_result(tmp_path / "other-profile")["result"]["status"] == "not_found"
def test_receipts_are_bounded_redacted_and_session_scoped(tmp_path, monkeypatch):
import time
from tools import process_registry_results as receipts
from tools.process_registry import MAX_OUTPUT_CHARS, ProcessRegistry, ProcessSession
from agent import redact
monkeypatch.setattr(redact, "_REDACT_ENABLED", False)
monkeypatch.setattr(receipts, "MAX_RETAINED_RESULTS", 2)
secret = "sk-" + "aB2cD3eF4gH5iJ6kL7mN8pQ9rS0tU1vW2xY3zA4bC5dE6fG7"
from gateway.session_context import scoped_current_session_id
from tools.process_registry_results import load_completed_results
monkeypatch.setenv("HERMES_SESSION_ID", "owner-session")
sessions = []
registry = ProcessRegistry()
for index in range(3):
session = ProcessSession(
id=f"proc_{index:012x}", command=f"echo {secret}", task_id=f"owner-{index}",
owner_task_id=f"owner-{index}", session_key=f"chat-{index}",
parent_session_id="owner-session",
started_at=time.time() - receipts.RESULT_RETENTION_SECONDS * 2,
output_buffer="x" * MAX_OUTPUT_CHARS + "\nréponse 世界\n" + secret,
exited=True, exit_code=index,
)
registry._running[session.id] = session
registry._move_to_finished(session)
sessions.append(session)
from hermes_constants import get_hermes_home
paths = list((get_hermes_home() / "logs" / "process-results").glob("*.json"))
assert len(paths) == 2
assert all(secret not in path.read_text(encoding="utf-8") for path in paths)
for path in paths:
raw = path.read_bytes()
assert not raw.startswith(b"\xef\xbb\xbf")
path.write_bytes(b"\xef\xbb\xbf" + raw)
fresh = ProcessRegistry()
assert fresh.get(sessions[0].id) is None
recovered = fresh.get(sessions[-1].id)
assert recovered is not None
assert "réponse 世界" in recovered.output_buffer
assert recovered.exited and recovered._completion_event.is_set()
assert recovered.owner_task_id == sessions[-1].owner_task_id
assert len(recovered.output_buffer) <= MAX_OUTPUT_CHARS
assert fresh.list_sessions() == [] # Status/liveness scans stay in memory.
assert [s["session_id"] for s in fresh.list_sessions(
task_id="owner-2", include_retained=True)] == [recovered.id]
assert fresh.list_sessions(task_id="unrelated", session_key="unrelated", include_retained=True) == []
assert fresh.get("proc_0000") is None # Ambiguous across durable results.
with scoped_current_session_id("unrelated-session"):
assert load_completed_results(recovered.id) == {}
assert fresh.get(recovered.id) is None
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session("owner-session", "cli")
db.create_session("delegated-child", "subagent", parent_session_id="owner-session")
with scoped_current_session_id("delegated-child"):
assert fresh.get(recovered.id) is None
db.end_session("owner-session", end_reason="compression")
db.create_session("owner-tip", "cli", parent_session_id="owner-session")
with scoped_current_session_id("owner-tip"):
assert fresh.get(recovered.id).output_buffer == recovered.output_buffer
finally:
db.close()
assert fresh.completion_queue.empty()
for path in paths:
expired = time.time() - receipts.RESULT_RETENTION_SECONDS - 1
os.utime(path, (expired, expired))
assert fresh.get(recovered.id) is None
# Multiplex readers must keep the producer's profile on native threads.
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
profile = tmp_path / "thread-profile"
token = set_hermes_home_override(profile)
try:
with scoped_current_session_id("thread-owner"):
child = registry.spawn_local(
shlex.join([Path(sys.executable).as_posix(), "-c", "print('SCOPED_RESULT')"]),
cwd=str(tmp_path), task_id="thread-task")
child._reader_thread.join(timeout=20)
assert not child._reader_thread.is_alive()
assert (profile / "logs" / "process-results" / f"{child.id}.json").exists()
assert "SCOPED_RESULT" in ProcessRegistry().read_log(child.id)["output"]
finally:
reset_hermes_home_override(token)
assert not (get_hermes_home() / "logs" / "process-results" / f"{child.id}.json").exists()