fix(managed-worker): do not offer delegate_task in a managed worker turn

A managed worker parent could select delegate_task, but the child path
always raised worker_child_registration_required: a delegated child needs
its own owner-registered execution (scope, outbox, epoch fence), and a
single-turn, opt-in managed worker has no way to reserve one. The model was
offered a tool whose only outcome was a refusal.

The worker now constructs its agent with the delegation toolset disabled
(WORKER_DISABLED_TOOLSETS). Disabled toolsets are subtracted last, so the
tool_search bridge and a restored tools[] pin drop delegate_task as well;
the refusal in _open_child_session_db stays as the backstop. In-process
(default) sessions are unchanged. Owner-registered child executions for
managed workers are a separate design (DECISION NEEDED in the lane notes).

Review: https://github.com/NousResearch/hermes-agent/pull/106742#discussion_r4230769955
(cherry picked from commit 9332c3f893f3a959a80bbfc2ae114e769c87de11)
This commit is contained in:
Teknium committed 2026-10-09 17:31:25 -07:00
1 parent 14dd2b98fe
commit ef52a09a55
2 files changed
+89

No files matched your search

+6
View File
@@ -23,6 +23,9 @@ import threading
logger = logging.getLogger(__name__)
MAX_FRAME = 4 * 1024 * 1024
# Toolsets a managed worker turn never offers (subtracted last, so the tool_search bridge and a
# restored tools[] pin drop them too): delegate_task's child has no owner registration here.
WORKER_DISABLED_TOOLSETS = ('delegation',)
BOOTSTRAP_FIELDS = {'version', 'home', 'scope', 'policy', 'api_key', 'text', 'route', 'user_id', 'chat_id',
'turn_author', 'safe_mode', 'ignore_user_config'}
# The bounded run_conversation fields a turn receipt keeps (outcome, exit reason, tokens, cost,
@@ -313,6 +316,9 @@ def execute(frame, channel):
agent = _construct_agent(frame, AIAgent, model=policy.model, provider=policy.provider, base_url=policy.base_url,
api_key=frame['api_key'], session_db=store, session_id=scope['session_id'],
enabled_toolsets=list(policy.toolsets), max_iterations=policy.max_turns,
# A delegated child needs its own owner-registered execution, which this single-turn
# worker cannot reserve; never offer a tool whose only outcome here is a refusal.
disabled_toolsets=list(WORKER_DISABLED_TOOLSETS),
reasoning_config=policy.reasoning_config,
# ``tool`` / ``oneshot`` are storage labels for a CLI-surface agent (session_policy.SURFACES).
platform=policy.platform if policy.source in ('tool', 'oneshot') else policy.source,
@@ -0,0 +1,83 @@
"""A managed worker turn never offers ``delegate_task``: its child would need an owner-registered
execution this single-turn worker cannot reserve, so the tool's only outcome would be a refusal
(real daemon, real managed worker, loopback model)."""
import asyncio
from contextlib import closing
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import sqlite3
import threading
import pytest
from tests.gateway.fixtures.local_recovery_probe import daemon, rpc, websocket
class Model(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
if body.get('messages'):
self.server.offered.append(json.dumps(body.get('tools') or []))
message = {'role': 'assistant', 'content': 'WORKER_DONE'}
frame = {'id': 'tools', 'model': 'm', 'choices': [{'index': 0, 'delta': message, 'finish_reason': 'stop'}],
'usage': {'prompt_tokens': 1, 'completion_tokens': 1, 'total_tokens': 2}}
if body.get('stream'):
payload, kind = ('data: ' + json.dumps(frame) + '\n\ndata: [DONE]\n\n').encode(), 'text/event-stream'
else:
frame['choices'][0]['message'] = frame['choices'][0].pop('delta')
payload, kind = json.dumps(frame).encode(), 'application/json'
self.send_response(200)
self.send_header('Content-Type', kind)
self.send_header('Content-Length', str(len(payload)))
self.end_headers()
self.wfile.write(payload)
@pytest.mark.platforms("linux")
def test_managed_worker_turn_does_not_offer_delegate_task(tmp_path):
root = Path(__file__).resolve().parents[2]
home, user = tmp_path / 'state', tmp_path / 'user'
home.mkdir(mode=0o700)
user.mkdir()
peer = ThreadingHTTPServer(('127.0.0.1', 0), Model)
peer.offered = []
threading.Thread(target=peer.serve_forever, daemon=True).start()
url = f'http://127.0.0.1:{peer.server_port}/v1'
(home / 'config.yaml').write_text(json.dumps({
'gateway': {'multiplex_profiles': False, 'managed_workers': True},
'model': {'provider': 'custom', 'default': 'm', 'base_url': url},
'auxiliary': {'title_generation': {'enabled': False}},
'platform_toolsets': {'cli': ['delegation', 'todo']}}))
env = {k: os.environ[k] for k in ('PATH', 'LANG', 'TZ') if k in os.environ}
env.update(HOME=str(user), USERPROFILE=str(user), HERMES_HOME=str(home), PYTHONPATH=str(root),
OPENAI_API_KEY='loopback-only', OPENAI_BASE_URL=url)
def rows(sql):
with closing(sqlite3.connect(f'file:{home / "state.db"}?mode=ro', uri=True)) as db:
return db.execute(sql).fetchall()
async def exercise(desc):
async with websocket(home, desc) as ws:
session = await rpc(ws, 'session.create', request_id='tools', source='cli', cwd=str(home), model='m',
provider='custom', base_url=url, api_key='loopback-only',
toolsets=['delegation', 'todo'], ignore_rules=True)
sid = session['result']['session_id']
submitted = await rpc(ws, 'prompt.submit', session_id=sid, input_id='run', text='hello')
assert 'result' in submitted, submitted
async with asyncio.timeout(90):
while rows("SELECT status FROM session_admissions WHERE request_id='run'") != [('terminal',)]:
await asyncio.sleep(.1)
try:
with daemon(root, home, env, barrier=False) as (_owner, desc):
asyncio.run(exercise(desc))
finally:
peer.shutdown()
peer.server_close()
assert peer.offered, 'the managed worker never reached the model'
assert all('todo_list' in tools for tools in peer.offered), peer.offered # the turn kept its other tools
assert not any('delegate_task' in tools for tools in peer.offered), peer.offered