Skip to content

Commit 4e4c256

Browse files
authored
feat: restrict primary worker agent to enforce delegation (#6)
* feat: restrict primary worker agent to enforce delegation * feat: removed read_file access from primary * chore: corrected stale plugin description * feat: switched to blocking delegate only tool calls * feat: allow primary agent to validate subagent work * feat: guide subagent to report consise findings * feat: allow inter-tool narration for subagent * feat: tidied up progress context * feat: strengthened primary agents reading delegation prompt * feat: improved semantic structure of agent prompts * feat: improved semantic structure of staste persistence * feat: order live stream events by timestamp
1 parent 76f3ea0 commit 4e4c256

8 files changed

Lines changed: 423 additions & 84 deletions

File tree

api/.hermes/config.base.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ plugins:
1313
- observability/langfuse
1414
- capture_reasoning # backfills reasoning_content/<think> into the langfuse span so thinking renders in the stream
1515
- synchronous_delegate # forces delegate_task to run synchronously so the real result returns in-band (captured to PROGRESS.md) and nothing is lost
16+
- worker_guard # blocks write_file/patch for the IMPLEMENT/REVISE primary (subagents unaffected) so every edit is delegated; the primary may still read to verify
1617

1718
browser:
1819
inactivity_timeout: 120

api/.hermes/plugins/persist_state/__init__.py

Lines changed: 118 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,15 @@
2424

2525
import json
2626
import os
27+
import re as _re
2728
import threading
2829
from typing import Any
2930

3031
_PROGRESS_REL = os.path.join(".olympian", "PROGRESS.md")
3132
_CHECKLIST_HEADER = "## Checklist"
3233
_FINDINGS_HEADER = "## Findings"
33-
_FINDINGS_BUDGET = 12_000 # max chars kept in Findings (drop oldest beyond this) — keep re-reads cheap
34-
_ENTRY_CAP = 2_000 # max chars of any single subagent report
34+
_FINDINGS_BUDGET = 50_000 # max chars kept in Findings (drop oldest beyond this)
35+
_ENTRY_CAP = 12_000 # max chars of any single subagent report (holds a thorough survey whole)
3536

3637
_LOCK = threading.Lock()
3738
_STATE: dict[str, Any] = {
@@ -111,6 +112,10 @@ def _seed() -> None:
111112
checklist, findings = _split(_read())
112113
_STATE["checklist"] = checklist
113114
_STATE["findings"] = findings
115+
# Continue Findings numbering from the highest existing entry so a retry (a fresh process
116+
# that resumes this file) doesn't restart at 1 and produce a jumbled "1, 2, 1" sequence.
117+
nums = [int(m) for m in _re.findall(r"(?m)^### (\d+)\.", findings or "")]
118+
_STATE["delegations"] = max(nums) if nums else 0
114119

115120

116121
def _flush() -> None:
@@ -154,29 +159,120 @@ def _render_checklist(result: Any) -> str:
154159
return "\n".join(lines)
155160

156161

157-
def _summarise_delegation(result: Any) -> str:
158-
def one(r: Any) -> str:
159-
if isinstance(r, dict):
160-
return str(
161-
r.get("summary")
162-
or r.get("error")
163-
or r.get("result")
164-
or json.dumps(r, ensure_ascii=False, default=str)
165-
)
162+
_THINK_TAGS = _re.compile(r"<(antThinking|think|thinking|reasoning)>.*?</\\1>", _re.DOTALL | _re.IGNORECASE)
163+
164+
165+
def _strip_thinking(text: str) -> str:
166+
return _THINK_TAGS.sub("", text).strip()
167+
168+
169+
_HEADING_RE = _re.compile(r"^[ \t]*(#{1,6})[ \t]+(.+?)[ \t]*#*$")
170+
171+
172+
def _relevel_headings(text: str, base: int = 4) -> str:
173+
"""Re-base the headings in captured (model-authored) goal/report text so the shallowest sits at
174+
``base`` — one level below a "### N." Findings entry (h3) — while preserving the content's own
175+
relative hierarchy. This keeps the agent's structure intact AND nested correctly under the entry,
176+
so its headings never sit above, or collide with, the structural ## Findings / ### N. markers
177+
when the file is injected into a prompt. Headings inside fenced code blocks are left untouched.
178+
"""
179+
lines = text.split("\n")
180+
181+
def heading_levels():
182+
in_fence = False
183+
for line in lines:
184+
s = line.lstrip()
185+
if s.startswith("```") or s.startswith("~~~"):
186+
in_fence = not in_fence
187+
continue
188+
m = None if in_fence else _HEADING_RE.match(line)
189+
if m:
190+
yield len(m.group(1))
191+
192+
levels = list(heading_levels())
193+
if not levels:
194+
return text
195+
shift = base - min(levels)
196+
197+
out: list[str] = []
198+
in_fence = False
199+
for line in lines:
200+
s = line.lstrip()
201+
if s.startswith("```") or s.startswith("~~~"):
202+
in_fence = not in_fence
203+
out.append(line)
204+
continue
205+
m = None if in_fence else _HEADING_RE.match(line)
206+
if m:
207+
level = max(1, min(6, len(m.group(1)) + shift))
208+
out.append("#" * level + " " + m.group(2).strip())
209+
else:
210+
out.append(line)
211+
return "\n".join(out)
212+
213+
214+
_DONE = {"completed", "complete", "success", "succeeded", "ok"}
215+
216+
217+
def _one_result(r: Any) -> str:
218+
"""Render a single child task result. Only a COMPLETED child contributes its summary; an
219+
incomplete one (timeout / max_iterations / error / interrupted) is recorded as a short marker
220+
instead of its truncated mid-thought narration — capturing that pollutes Findings."""
221+
if not isinstance(r, dict):
166222
return str(r)
223+
status = str(r.get("status") or "").strip().lower()
224+
exit_reason = str(r.get("exit_reason") or "").strip().lower()
225+
# No status fields at all (older/other shapes) → treat as a plain result.
226+
if (status or exit_reason) and status not in _DONE and exit_reason not in _DONE:
227+
why = status or exit_reason or "unknown"
228+
detail = str(r.get("error") or "").strip() or "cut off before producing a final report"
229+
return f"⚠️ Sub-agent did not finish (status: {why}) — {detail[:200]}"
230+
return str(r.get("summary") or r.get("result") or r.get("error") or "")
231+
232+
233+
def _summarise_delegation(result: Any) -> str:
234+
one = _one_result
167235

168236
if isinstance(result, str):
169237
try:
170238
result = json.loads(result)
171239
except Exception:
172-
return result.strip()[:_ENTRY_CAP]
173-
text = "\n".join(one(r) for r in result) if isinstance(result, list) else one(result)
174-
text = text.strip()
240+
return _strip_thinking(result)[:_ENTRY_CAP]
241+
242+
# delegate_task returns {"results": [ {summary,...}, ... ], "note": ...}; older/other shapes
243+
# may be a bare list or dict.
244+
if isinstance(result, dict) and isinstance(result.get("results"), list):
245+
items = result["results"]
246+
elif isinstance(result, list):
247+
items = result
248+
else:
249+
items = [result]
250+
251+
text = "\n".join(p for p in (one(r) for r in items) if p.strip())
252+
text = _strip_thinking(text)
253+
text = _relevel_headings(text)
175254
if len(text) > _ENTRY_CAP:
176255
text = text[:_ENTRY_CAP].rstrip() + " …[trimmed]"
177256
return text
178257

179258

259+
def _heading_label(goal: Any, cap: int = 100) -> str:
260+
"""A short one-line label for a Findings heading. The primary writes the whole task as a single
261+
long line, so we take the first line, cut it at the first sentence/clause boundary if that is
262+
short enough, then hard-cap on a word boundary."""
263+
line = str(goal or "").strip().splitlines()
264+
line = line[0].strip() if line else ""
265+
# Cut at the first clause/sentence boundary that is actually present and short enough.
266+
for sep in (": ", ". "):
267+
if sep in line:
268+
head = line.split(sep, 1)[0]
269+
if 0 < len(head) <= cap:
270+
return head
271+
if len(line) > cap:
272+
line = line[:cap].rsplit(" ", 1)[0].rstrip(" ,.;:") + "…"
273+
return line
274+
275+
180276
def _is_dispatch_ack(result):
181277
"""A background delegation's post-hook result is only a dispatch acknowledgement, not the
182278
subagent's report (that arrives asynchronously) — recognise it so we don't record it."""
@@ -254,11 +350,14 @@ def on_post_tool_call(
254350
if not _is_dispatch_ack(result):
255351
_STATE["delegations"] += 1
256352
n = _STATE["delegations"]
257-
goal = ""
258-
if isinstance(args, dict):
259-
first = str(args.get("goal", "")).strip().splitlines()
260-
goal = first[0] if first else ""
261-
entry = (f"### {n}. {goal}".rstrip()) + "\n" + _summarise_delegation(result) + "\n"
353+
goal_full = str(args.get("goal", "")).strip() if isinstance(args, dict) else ""
354+
label = _heading_label(goal_full)
355+
# Short heading for readability; the full goal is kept as body so no context is
356+
# lost, then the subagent's report.
357+
entry = f"### {n}. {label}".rstrip() + "\n"
358+
if goal_full and goal_full != label:
359+
entry += f"**Goal:** {_relevel_headings(goal_full)}\n\n"
360+
entry += _summarise_delegation(result) + "\n"
262361
prior = _STATE["findings"] or ""
263362
_STATE["findings"] = (prior + "\n\n" + entry) if prior else entry
264363
_flush()
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""worker_guard — force the IMPLEMENT/REVISE primary to delegate every edit.
2+
3+
The primary may READ files (to verify a sub-agent's work), but it must not EDIT them: every change
4+
goes through a sub-agent so the work is delegated, captured in PROGRESS.md's Findings, and the
5+
primary's context stays manageable. Two block paths, both primary-only:
6+
7+
* the write tools — write_file / patch
8+
* file-mutating shell commands — `sed -i`, output redirects (> / >>), tee, mv/cp/rm/…, so the
9+
primary can't bypass the tool block via terminal. Test/build commands and harmless redirects
10+
(2>&1, > /dev/null) are left alone.
11+
12+
We cannot remove the write tools from the parent's toolset — Hermes caps a child's toolset to a
13+
subset of the parent's (see delegate_tool's intersection), so that would strip editing from
14+
sub-agents too. Instead we keep the tools and BLOCK them for the primary only, via a pre_tool_call
15+
directive. The prompt tells the model they're disabled so it delegates up front.
16+
17+
Primary vs sub-agent is decided by task_id without relying on call ordering: the first tool call in
18+
the process is the parent's (a child only exists after a delegate_task), and any task_id seen WHILE a
19+
delegation is in flight is a sub-agent's. The hooks never raise — a guard failure must not break a
20+
run (it just declines to block).
21+
"""
22+
from __future__ import annotations
23+
24+
import os
25+
import re
26+
import shlex
27+
import threading
28+
from typing import Any, Optional
29+
30+
_WORKER_PHASES = {"IMPLEMENT", "REVISE"}
31+
# Write tools — the primary may still read (read_file/search_files/cat) to verify a sub-agent.
32+
_BLOCKED = {"write_file", "patch"}
33+
34+
# Shell commands that mutate the filesystem (checked per pipeline segment, ignoring a leading `cd`).
35+
_WRITE_CMDS = {
36+
"tee", "mv", "cp", "rm", "rmdir", "touch", "mkdir", "dd", "truncate", "install", "ln",
37+
"patch", "chmod", "chown",
38+
}
39+
# A redirect operator with its target: optional leading fd (or &), > or >>, then the target token.
40+
_REDIR = re.compile(r"(?:\d*|&)\s*>>?\s*(\S+)")
41+
_SEGMENTS = re.compile(r"\|\||&&|;|\|")
42+
43+
_LOCK = threading.Lock()
44+
_STATE: dict[str, Any] = {
45+
"primary": None, # the primary agent's task_id, once known
46+
"active": 0, # in-flight delegate_task calls
47+
"subagents": set(), # task_ids identified as delegated sub-agents
48+
}
49+
50+
51+
def _phase_ok() -> bool:
52+
return (os.environ.get("OLYMPIAN_PHASE") or "").strip().upper() in _WORKER_PHASES
53+
54+
55+
def _has_write_redirect(cmd: str) -> bool:
56+
for m in _REDIR.finditer(cmd):
57+
target = m.group(1).strip("'\"")
58+
if target == "/dev/null" or re.fullmatch(r"&?\d+", target):
59+
continue # > /dev/null or an fd dup (2>&1, >&2) — not a file write
60+
return True
61+
return False
62+
63+
64+
def _has_write_command(cmd: str) -> bool:
65+
for seg in _SEGMENTS.split(cmd):
66+
seg = seg.strip()
67+
if not seg or seg.startswith("cd "):
68+
continue
69+
try:
70+
parts = shlex.split(seg)
71+
except Exception:
72+
parts = seg.split()
73+
if not parts:
74+
continue
75+
word = parts[0]
76+
if word in _WRITE_CMDS:
77+
return True
78+
if word == "sed" and any(p == "-i" or p.startswith("-i") for p in parts[1:]):
79+
return True # in-place edit; plain sed is a read/filter
80+
return False
81+
82+
83+
def _is_file_write_command(cmd: Optional[str]) -> bool:
84+
cmd = (cmd or "").strip()
85+
if not cmd:
86+
return False
87+
return _has_write_redirect(cmd) or _has_write_command(cmd)
88+
89+
90+
def on_pre_tool_call(
91+
*, tool_name: str = "", task_id: str = "", args: Optional[dict] = None, **_: Any
92+
) -> Any:
93+
if not _phase_ok():
94+
return None
95+
try:
96+
with _LOCK:
97+
if tool_name == "delegate_task":
98+
if _STATE["primary"] is None and task_id:
99+
_STATE["primary"] = task_id # only the primary delegates
100+
_STATE["active"] += 1
101+
return None
102+
103+
# A tool call during an in-flight delegation, under a non-primary id, is a sub-agent's.
104+
if _STATE["active"] > 0 and task_id and task_id != _STATE["primary"]:
105+
_STATE["subagents"].add(task_id)
106+
if _STATE["primary"] is None and task_id:
107+
_STATE["primary"] = task_id # first non-delegate tool call = the parent
108+
is_subagent = task_id in _STATE["subagents"]
109+
110+
if is_subagent:
111+
return None # sub-agents do the editing — never block them
112+
113+
if tool_name in _BLOCKED:
114+
return {
115+
"action": "block",
116+
"message": (
117+
f"`{tool_name}` is disabled for you (the orchestrator) — you must not edit files "
118+
"yourself. Delegate this change to a sub-agent with delegate_task; it edits in its "
119+
"own context and returns the result to you. (You can still read files directly to "
120+
"verify a sub-agent's work.)"
121+
),
122+
}
123+
124+
if tool_name == "terminal" and _is_file_write_command((args or {}).get("command")):
125+
return {
126+
"action": "block",
127+
"message": (
128+
"You must not modify files through the shell (no in-place sed, > / >> redirects, "
129+
"tee, mv/cp/rm, etc.) — terminal is for running tests/builds only. Delegate this "
130+
"change to a sub-agent with delegate_task instead."
131+
),
132+
}
133+
except Exception:
134+
return None
135+
return None
136+
137+
138+
def on_post_tool_call(*, tool_name: str = "", **_: Any) -> Any:
139+
if not _phase_ok():
140+
return None
141+
try:
142+
if tool_name == "delegate_task":
143+
with _LOCK:
144+
_STATE["active"] = max(0, _STATE["active"] - 1)
145+
except Exception:
146+
pass
147+
return None
148+
149+
150+
def register(ctx) -> None:
151+
ctx.register_hook("pre_tool_call", on_pre_tool_call)
152+
ctx.register_hook("post_tool_call", on_post_tool_call)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
name: worker_guard
2+
version: 1.0.0
3+
description: "Blocks write_file/patch AND file-mutating shell commands (sed -i, > / >> redirects, tee, mv/cp/rm, ...) for the IMPLEMENT/REVISE PRIMARY agent (sub-agents unaffected) so every edit is delegated. The primary may still read files (read_file/search_files/cat) and run tests/builds via terminal to verify a sub-agent's work. We block rather than remove the write tools because Hermes caps a child's toolset to a subset of the parent's, so removing them from the parent would also strip editing from sub-agents."
4+
hooks:
5+
- pre_tool_call
6+
- post_tool_call

0 commit comments

Comments
 (0)