diff --git a/README.cn.md b/README.cn.md index 1661b6db..bbbede12 100644 --- a/README.cn.md +++ b/README.cn.md @@ -168,7 +168,7 @@ TUI 内:`Esc` 中断流式输出,`Ctrl+C` / `exit` 退出(配置与 GUI | | Claude Code | Codex | DeepSeek Harness | **EMRG** | **Grok Bot** | |---|---|---|---|---|---| | AI 驱动编程 | ✅ | ✅ | ✅ | ✅ | ✅ | -| 工具调用 (`bash`/`pwsh`、`read`、`write`、`edit`、`glob`、`grep`、`submit_rant`、`subagent`) | ✅ | ✅ | ✅ | ✅ | ✅ | +| 工具调用 (`bash`/`pwsh`、`read`、`write`、`edit`、`glob`、`grep`、`submit_rant`、`subagent`、`boss`) | ✅ | ✅ | ✅ | ✅ | ✅ | | 会话记忆与上下文 | ✅ | ✅ | ✅ | ✅ | ✅ | | `/` 命令自动补全 | ✅ | ✅ | ✅ | ✅ | ~ *@ mention 建议 + 命令面板* | | ESC 中断 | ✅ | ✅ | — | ✅ | ✅ | diff --git a/README.md b/README.md index 2e16d1c5..ff107f77 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ In the TUI: `Esc` interrupts streaming, `Ctrl+C` / `exit` quits (config is share | | Claude Code | Codex | DeepSeek Harness | **EMRG** | **Grok Bot** | |---|---|---|---|---|---| | AI-powered coding | ✅ | ✅ | ✅ | ✅ | ✅ | -| Tool-calling (`bash`/`pwsh`, `read`, `write`, `edit`, `glob`, `grep`, `submit_rant`, `subagent`) | ✅ | ✅ | ✅ | ✅ | ✅ | +| Tool-calling (`bash`/`pwsh`, `read`, `write`, `edit`, `glob`, `grep`, `submit_rant`, `subagent`, `boss`) | ✅ | ✅ | ✅ | ✅ | ✅ | | Session memory & context | ✅ | ✅ | ✅ | ✅ | ✅ | | `/` command autocomplete | ✅ | ✅ | ✅ | ✅ | ~ *@ mention + command palette* | | ESC interrupt | ✅ | ✅ | — | ✅ | ✅ | diff --git a/emrg/server/competition_prompt.md b/emrg/server/competition_prompt.md index 2c5da142..0e8d2085 100644 --- a/emrg/server/competition_prompt.md +++ b/emrg/server/competition_prompt.md @@ -33,6 +33,8 @@ You are EMRG's competition participation module. **Every cycle you MUST fully ex **Do not skip. Execute even if "everything looks fine".** +**The round's first step is to call `boss`** — a separate session that owns this task's direction while this one does the work — and to treat its answer as the round's stance; whatever the answer does not mention is still this session's to decide. A session that is not offered `boss` has no owner to ask, and carries on without one. + #### 0.1 Environment verification - **Browser first**: `browser-harness` (`BU_CDP_URL=http://127.0.0.1:57000`) reuses the host's already-logged-in browser. Competition platforms (Tianchi, Kaggle, DataFountain, HuggingFace) require login for rules pages, data download and submission — **the login state exists only in the real browser**. diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index d6480dc3..cac67e90 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -161,6 +161,7 @@ def resolve_client_tier(session, req_sandbox: str | None = None) -> str: from emrg.tools.grep_tool import GrepTool from emrg.tools.submit_rant_tool import SubmitRantTool from emrg.tools.subagent_tool import SubagentTool +from emrg.tools.boss_tool import BossTool from emrg.skills.loader import load_skills from emrg.skills.registry import ensure_catalog_file, load_catalog_skills, skill_is_managed from emrg.server.rants import append_rant, list_rants @@ -204,13 +205,18 @@ def default_tool_names(platform_name: str | None = None) -> list[str]: def scheduled_task_tool_names(platform_name: str | None = None) -> list[str]: - """The tool names a scheduled task's round may use (issue #2465). + """The tool names a scheduled task's round may use (issue #2465, #2467). - Today the same set a conversation gets. The split exists so that a tool - meant only for scheduled work (a review/owner role) can be added **here - alone** — no second mechanism inside the tool asking "who called me?". + A host conversation's set, plus ``boss`` — the owner role, which is the first + tool the split was built for and the reason it exists: adding it here alone is + what keeps it out of a conversation, with no second mechanism inside the tool + asking "who called me?". + + ``boss`` goes last so the shared prefix of the two lists is the whole of + ``default_tool_names``: a reader comparing them sees one append, not a + reshuffle. """ - return default_tool_names(platform_name) + return default_tool_names(platform_name) + ["boss"] def effective_tool_names( @@ -708,6 +714,7 @@ def build_shell_tool( def build_tool_registry( sandbox_config: Optional[SandboxConfig] = None, run_subagent_cb=None, + run_boss_cb=None, platform_name: str | None = None, ) -> ToolRegistry: """The daemon's tool set, built in one place so that its size is a reading. @@ -746,6 +753,10 @@ def build_tool_registry( # The runner is the daemon's own method, so the tool has no path to the # session machinery of its own: everything that owns state stays there. tools.register(SubagentTool(run_subagent_cb)) + # Registered like any other tool and offered to scheduled tasks alone: which + # sessions may call it is `scheduled_task_tool_names()`'s fact, not this one's + # (issue #2467). + tools.register(BossTool(run_boss_cb)) return tools @@ -908,7 +919,9 @@ def __init__(self, llm_config: LlmConfig) -> None: self._pending_web_auth_proc: Optional[asyncio.subprocess.Process] = None # Build tool registry - self.tools = build_tool_registry(self._sandbox_config, self._run_subagent_tool) + self.tools = build_tool_registry( + self._sandbox_config, self._run_subagent_tool, self._run_boss_tool, + ) logger.info("tools registered: %s", self.tools.names) # Skills are **not** held here. A snapshot taken at startup cannot see a skill @@ -4340,13 +4353,13 @@ def _inject_tool_arguments( # safe form is the unconditional one: whatever the session holds, # including nothing, is what the call runs with. args["writable_roots"] = session.sandbox_roots - if tc_name == "subagent": + if tc_name in ("subagent", "boss"): # The parent's identity and cwd are facts the model must not choose - # (issue #2451): the slot is derived from both, so a caller that named - # its own would name a wrong one. Injected unconditionally, so a value - # the model put in the call is replaced rather than honoured — the same - # rule the writable roots follow, and for the same reason: the parent of - # a delegation cannot be the delegated. + # (issue #2451, #2467): the slot is derived from both, so a caller that + # named its own would name a wrong one. Injected unconditionally, so a + # value the model put in the call is replaced rather than honoured — the + # same rule the writable roots follow, and for the same reason: the + # parent of a delegation cannot be the delegated. args["_parent_session_id"] = session.session_id args["_parent_cwd"] = str(session.cwd) # The child inherits **this turn's** set (issue #2465). A delegation @@ -7799,6 +7812,202 @@ def _subagent_report( f"{answer}" ) + # ── The owner role (issue #2467) ─────────────────────────────────────── + + #: The fixed directory a task's owner session lives in, under the parent's + #: ``sub-agent/``. One owner per parent, so it is a name and not a slot: the + #: nine ``subagent`` slots are least-recently-closed and emptied on use, which + #: is right for a job that ends when it answers and wrong for a role that is + #: held across rounds. + BOSS_DIR = "sub-boss" + + async def _run_boss_tool( + self, *, parent_session_id: str, parent_cwd: str, focus: str = "", + timeout_s: int, intent: str = "", parent_tools: list[str] | None = None, + ) -> dict: + """Run the owner session for one `boss` call. Answers text or an error. + + The bound covers everything, as `subagent`'s does, and is read the same + way — by *whether the child finished*, not by catching a timeout: the tool + loop absorbs the cancellation it receives and returns normally, so a + cancelled owner would look like a finished one that said nothing. + + There is no slot to release: the directory is fixed and retained, so a + timed-out call leaves nothing occupied and the next call resumes the same + session. + """ + state: dict = {"child": None} + child_task = asyncio.create_task(self._run_boss_child( + parent_session_id=parent_session_id, + parent_cwd=parent_cwd, + focus=focus, + intent=intent, + state=state, + parent_tools=parent_tools, + )) + try: + done, _ = await asyncio.wait({child_task}, timeout=timeout_s) + except asyncio.CancelledError: + # The executor's own round was cancelled. The owner is not left running + # behind it. + child_task.cancel() + await asyncio.gather(child_task, return_exceptions=True) + raise + if done: + return child_task.result() + child_task.cancel() + await asyncio.gather(child_task, return_exceptions=True) + logger.warning( + "[boss] timed out after %ss: parent=%s child=%s", + timeout_s, parent_session_id, state["child"], + ) + return {"error": ( + f"boss: the owner did not answer within {timeout_s}s, so its turn was " + "cancelled. Nothing from it was delivered. Owner session: " + f"{state['child']}. Raise `timeout_s`, or narrow `focus` to the one " + "thing you need judged." + )} + + async def _run_boss_child( + self, *, parent_session_id: str, parent_cwd: str, focus: str, + intent: str, state: dict, parent_tools: list[str] | None = None, + ) -> dict: + """The body of one owner call: open the retained session, run it, report.""" + started = time.monotonic() + parent = self._get_or_create_session(parent_session_id, Path(parent_cwd)) + + depth = parent.depth + if depth >= self.SUBAGENT_MAX_DEPTH: + logger.info( + "[boss] refused: delegation depth %d reached (parent=%s)", + depth, parent_session_id, + ) + return {"error": ( + f"boss: refused — this session is already {depth} delegations deep " + f"and the limit is {self.SUBAGENT_MAX_DEPTH}. Nothing was started. " + "An owner is asked for by the session that does the work, not by " + "one it has already delegated to." + )} + + # The id carries the parent's for the same reason a slot's does: the busy, + # cancel and turn tables are keyed by id across the whole daemon, so a + # fixed `boss` would make two tasks' owners indistinguishable there. + child_id = f"boss-{parent_session_id}" + state["child"] = child_id + parent_session_dir = ( + Path(parent_cwd) / ".emrg" / "sessions" / parent_session_id + ) + # The cwd is two levels below the parent's session directory, which is how + # the owner derives the history it must read: `/sub-agent/sub-boss`. + boss_cwd = parent_session_dir / "sub-agent" / self.BOSS_DIR + boss_cwd.mkdir(parents=True, exist_ok=True) + parent_history = parent_session_dir / "history.jsonl" + + prompt = _get_jinja_env().get_template("boss.j2").render( + parent_history=str(parent_history), + parent_session=parent_session_id, + # Read by the template's own `{% if focus %}`, so an empty string and a + # missing key agree: no particular question was asked. + focus=focus or "", + ) + + # Load-or-create, never delete: the retained session keeps its history, its + # memory and its own compaction between rounds. That is what makes the + # several thousand characters of mission a cost paid once rather than + # every round (issue #2467, requirement 3). + child = self._get_or_create_session(child_id, boss_cwd) + child.set_depth(depth + 1) + # The owner's tier is the parent's, never its own: authorization a caller + # can choose for the thing it is authorizing is not a boundary. + if parent.sandbox: + child.set_sandbox(parent.sandbox) + if parent.sandbox_roots: + try: + child.set_sandbox_roots(parent.sandbox_roots) + except (ValueError, OSError): + logger.warning("[boss] could not inherit the parent's writable roots") + + req = TaskRequest( + id=f"boss-{int(started * 1000)}", + session_id=child_id, + cwd=str(boss_cwd), + prompt=prompt, + timestamp="", + sandbox=parent.sandbox, + # The owner inherits the executor's tool set the way any child does + # (#2465). It includes `boss` itself; the depth limit above is what + # bounds an owner that asks for an owner. + tools=parent_tools, + ) + assistants_before = sum( + 1 for r in child._read_history() if r.get("role") == "assistant" + ) + logger.info( + "[boss] start: parent=%s child=%s depth=%d intent=%s", + parent_session_id, child_id, depth + 1, + redact_string(intent) if intent else "-", + ) + + self._session_busy[child_id] = True + cancel_event = asyncio.Event() + self._session_cancel[child_id] = cancel_event + try: + await self._run_tool_loop_locked(req, None, child, cancel_event) + finally: + # "Closed" is the whole teardown, the memory write included — the same + # ordering the slot runners need, and here it is also what makes the + # next round read a settled history. + await self._await_memory_reflection(child_id) + self._session_busy[child_id] = False + self._session_cancel.pop(child_id, None) + + records = child._read_history() + rounds = max(0, sum( + 1 for r in records if r.get("role") == "assistant" + ) - assistants_before) + answer = "" + for record in reversed(records): + if record.get("role") != "assistant": + continue + content = record.get("content") + if isinstance(content, str) and content.strip(): + answer = content + break + elapsed = time.monotonic() - started + logger.info( + "[boss] done: child=%s rounds=%d elapsed=%.1fs", child_id, rounds, elapsed, + ) + return {"text": self._boss_report(child_id, boss_cwd, answer, rounds, elapsed)} + + @staticmethod + def _boss_report( + child_id: str, boss_cwd: Path, answer: str, rounds: int, elapsed: float, + ) -> str: + """What the executor's round receives: the stance, and where it came from. + + The cap is `_SUBAGENT_ANSWER_CHARS`, shared rather than duplicated: the two + subjects are one question — how much of a child session's answer reaches the + parent's round — and a second constant would be a second number to keep in + step with it. + """ + if not answer: + answer = "(the owner produced no answer)" + elif len(answer) > _SUBAGENT_ANSWER_CHARS: + answer = ( + answer[:_SUBAGENT_ANSWER_CHARS] + + f"\n\n… (truncated — the answer ran to {len(answer)} characters, " + f"the first {_SUBAGENT_ANSWER_CHARS} are shown)" + ) + return ( + "The owner answered. Treat this as this round's stance; anything it " + "does not mention is still yours to decide.\n" + f"- session: {child_id}\n" + f"- cwd: {boss_cwd}\n" + f"- rounds: {rounds}\n" + f"- elapsed: {elapsed:.1f}s\n\n" + f"{answer}" + ) + def _maybe_reflect_memory( self, session: Session, user_prompt: str, assistant_content: str, tool_names: list[str] | None = None, diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index 6e2590d6..91ed2b2d 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -29,6 +29,8 @@ You are EMRG's self-evolution module. **Every cycle executes the whole loop — Get able to work: gh installed, role known, source synced. +**The round's first step is to call `boss`** — a separate session that owns this task's direction while this one does the work — and to treat its answer as the round's stance; whatever the answer does not mention is still this session's to decide. A session that is not offered `boss` has no owner to ask, and carries on without one. + #### 0.1 GitHub CLI **Install gh CLI** (required for GitHub operations; install if missing): diff --git a/emrg/server/feishu_prompt.md b/emrg/server/feishu_prompt.md index 3e680652..013632d9 100644 --- a/emrg/server/feishu_prompt.md +++ b/emrg/server/feishu_prompt.md @@ -22,6 +22,8 @@ invented, never defaulted. ### 1. Read +**The round's first step is to call `boss`** — a separate session that owns this task's direction while this one does the work — and to treat its answer as the round's stance; whatever the answer does not mention is still this session's to decide. A session that is not offered `boss` has no owner to ask, and carries on without one. + Messages come from `lark-cli`, which speaks the Feishu IM API. If it is not on `PATH`, look in the usual install locations before calling it blocked. diff --git a/emrg/server/ios_app_prompt.md b/emrg/server/ios_app_prompt.md index 90c2a063..3d1bf8bf 100644 --- a/emrg/server/ios_app_prompt.md +++ b/emrg/server/ios_app_prompt.md @@ -28,6 +28,8 @@ You are EMRG as the **product owner** of iOS applications: you choose what to bu **Do not skip it, including the rounds where "everything looks fine".** +**The round's first step is to call `boss`** — a separate session that owns this task's direction while this one does the work — and to treat its answer as the round's stance; whatever the answer does not mention is still this session's to decide. A session that is not offered `boss` has no owner to ask, and carries on without one. + #### 0.1 Instance values come from `extra_prompt`, never from this file This template ships identically to every EMRG instance, so it carries **no instance-specific value at all**. Everything about *your* account is read from the task's `extra_prompt`, which the host writes into `tasks.yml`: the Apple developer team id, the App Store Connect API key's path and issuer id, which bundle-id prefix to use, the budget, and any preference about which category to prefer or avoid. diff --git a/emrg/server/journal_prompt.md b/emrg/server/journal_prompt.md index e95cb384..db3af3c0 100644 --- a/emrg/server/journal_prompt.md +++ b/emrg/server/journal_prompt.md @@ -40,6 +40,8 @@ do. The host unblocks it by adding `author_id` to this task's `config` in tasks. **Do not skip. Execute even if "everything looks fine".** +**The round's first step is to call `boss`** — a separate session that owns this task's direction while this one does the work — and to treat its answer as the round's stance; whatever the answer does not mention is still this session's to decide. A session that is not offered `boss` has no owner to ask, and carries on without one. + #### 0.1 Environment verification ```bash diff --git a/emrg/server/open_source_prompt.md b/emrg/server/open_source_prompt.md index f18738c8..0fb33e84 100644 --- a/emrg/server/open_source_prompt.md +++ b/emrg/server/open_source_prompt.md @@ -29,6 +29,8 @@ You are EMRG's open-source participation module: this task contributes to a repo Get able to work: `gh` authenticated, the role known and locked, the source synced. +**The round's first step is to call `boss`** — a separate session that owns this task's direction while this one does the work — and to treat its answer as the round's stance; whatever the answer does not mention is still this session's to decide. A session that is not offered `boss` has no owner to ask, and carries on without one. + #### 0.1 GitHub CLI ```bash diff --git a/emrg/server/paper_prompt.md b/emrg/server/paper_prompt.md index cb314b9f..28aeead9 100644 --- a/emrg/server/paper_prompt.md +++ b/emrg/server/paper_prompt.md @@ -16,6 +16,8 @@ You are EMRG's paper writing module. **Every writing session MUST fully execute ### 0. Phase Assessment (MUST run first every cycle) +**The round's first step is to call `boss`** — a separate session that owns this task's direction while this one does the work — and to treat its answer as the round's stance; whatever the answer does not mention is still this session's to decide. A session that is not offered `boss` has no owner to ask, and carries on without one. + Paper writing has four qualitatively distinct phases, each with its own goals, loop logic, and exit conditions. **Before starting this round's work, first determine which phase you are in**: | Phase | Goal | Core Activity | Exit Condition | diff --git a/emrg/server/promote_prompt.md b/emrg/server/promote_prompt.md index 8a12cee6..42626b05 100644 --- a/emrg/server/promote_prompt.md +++ b/emrg/server/promote_prompt.md @@ -22,6 +22,8 @@ You are EMRG's community promotion module. **Every cycle you MUST fully execute Run this loop every round; never stop at "executed": +**The round's first step is to call `boss`** — a separate session that owns this task's direction while this one does the work — and to treat its answer as the round's stance; whatever the answer does not mention is still this session's to decide. A session that is not offered `boss` has no owner to ask, and carries on without one. + 1. **Plan** — set this round's promotion intent: promote what, where, to whom (answer §5 question 1 before acting) 2. **Do** — execute the Participatory Four Steps 3. **Check** — **actively measure the effect** with quantifiable signals: star/fork deltas, article/blog exposure & interaction, comment interaction rate, platform/search ranking, feedback collected (see §6). Read the numbers yourself — do not wait for the host to point them out diff --git a/emrg/server/prompts/boss.j2 b/emrg/server/prompts/boss.j2 new file mode 100644 index 00000000..91ffce06 --- /dev/null +++ b/emrg/server/prompts/boss.j2 @@ -0,0 +1,45 @@ +{# The prompt a `boss` call hands its child session. + # + # A template rather than a string in the code, for the same reason + # `memory_compaction.j2` is one: the wording is the host's to edit, and the next + # call reads the new text without a restart. + # + # What it deliberately does NOT do is carry the mission. The parent cannot be + # asked for it: a parent writing the brief is the one thing this tool exists to + # avoid, because a brief is a summary and a summary loses the position. So the + # child is given the path to the parent's own history and told to read the + # mission there, where it is verbatim and complete. + #} +You are the owner of this task. The session that called you is the one doing the +work: it is the executor, and you set the direction. + +You are not told what the task is, and that is on purpose — a summary written by +the executor would be the executor's own reading of the task. Read it yourself: + +- **The mission** is the last record whose `role` is `user` in + `{{ parent_history }}`. That record is the whole task statement this run was + given, including anything appended to it. Nothing the executor says about the + mission replaces it; if the two disagree, the mission is right. +- **What has happened since** is the rest of that file: the tool calls, their + results, and what the executor concluded after each. Read its recent end. It is + long — read a window, not the whole file, and use `grep` when you know what you + are looking for. + +Then answer with a stance. You are not grading the work and not listing problems; +you are deciding how this task is to be handled now. + +- Say what the coming rounds should aim at, and what is not acceptable. +- Say what may be traded away and what may not. +- Say where to go next when the current way is exhausted. +- Be concrete enough that the executor can act on the answer without calling you + again for it. + +Write one short answer, in the imperative, addressed to the executor. Do not +restate the mission, do not narrate what you read, and do not hedge: an answer +that lists considerations without choosing between them leaves the executor where +it started. +{% if focus %} +The executor especially wants your judgement on this: + +{{ focus }} +{% endif %} diff --git a/emrg/tools/boss_tool.py b/emrg/tools/boss_tool.py new file mode 100644 index 00000000..fe934f20 --- /dev/null +++ b/emrg/tools/boss_tool.py @@ -0,0 +1,196 @@ +"""BossTool — the task's owner, in a session of its own, deciding the stance. + +Issue #2467. Every scheduled task is one agent that decides the direction and does +the work in the same round, and those two jobs pull against each other: the round +that must finish something now is the worst judge of whether it should be doing it +at all. Two ways of separating them were tried and both failed. Baking review +semantics into the generic ``subagent`` schema was refused — a general tool with a +special purpose is no longer general. Writing "call a subagent first and brief it +thus" into the templates ran at 16% → 56% → 31% of rounds, so the wording is not +the lever, and a parent-written brief is structurally wrong anyway: a brief is a +summary, and the position the owner exists to supply is what a summary drops. + +So the owner is a tool of its own, with three properties that make it one: + +**It reads the mission itself.** The child's cwd is +``/sub-agent/sub-boss``, so the parent's own session directory +is two levels up and its ``history.jsonl`` is right there — the last ``user`` +record is the entire task statement, ``extra_prompt`` included. The parent supplies +no text: ``focus`` is an optional *question*, never the brief. Measured on the +evolution task, that statement runs to tens of thousands of characters, and every +one of them is the position this role needs. + +**It is not cleared between rounds.** The nine ``subagent`` slots are +least-recently-closed and emptied on use, which is right for a job that ends when +it answers. This one is a role held across rounds: emptied, it would re-read the +whole mission every round — paying for it again, and forgetting what it saw last +round. So it gets one fixed directory, ``sub-boss``, created on first use and +reused; it disappears with the parent's ``sub-agent/`` like anything else there. + +**It is offered to scheduled tasks and not to host conversations.** That is not +decided here and must not be: the tool is registered once, and +``scheduled_task_tool_names()`` in the daemon is the only list that names it. A +role inside the tool asking "who called me?" would be a second mechanism for a +fact the producer already knows. + +The bound (``timeout_s``, default as ``subagent``'s) covers the whole call, and a +timed-out call leaves nothing occupied, because there is no slot to occupy. The +depth limit is the delegation depth every child session carries: an owner that +delegates forever is bounded by the same rule as anything else it starts. +""" + +from __future__ import annotations + +from typing import Awaitable, Callable + +from emrg.server.tool_types import ToolDefinition, ToolResult +from emrg.tools.base import ToolExecutor + +#: The runner the daemon supplies: ``await runner(parent_session_id=…, +#: parent_cwd=…, focus=…, timeout_s=…, intent=…, parent_tools=…)`` answering +#: either ``{"text": …}`` or ``{"error": …}``. ``parent_tools`` is the parent +#: turn's effective tool set (#2465), which the child inherits. +Runner = Callable[..., Awaitable[dict]] + +#: Generous enough for a real read of the parent's history, short enough that a +#: wedged owner session cannot hold the parent's round for ever. +DEFAULT_TIMEOUT_S = 600 + + +class BossTool(ToolExecutor): + """Ask this task's owner session what the coming rounds' stance is.""" + + def __init__(self, runner: Runner) -> None: + self._runner = runner + + def definition(self) -> ToolDefinition: + return ToolDefinition( + name="boss", + description=( + "Ask the owner of this task what the coming rounds should do. The " + "owner is a separate session that holds the direction while this " + "session does the work: it reads this session's own history for " + "the mission and for what has happened, so you do not describe " + "either — and what you get back is a stance to treat as binding " + "for this round, not a list of problems to consider. Anything the " + "answer does not mention is still yours to decide. It is offered " + "to scheduled tasks only, keeps its memory across rounds, and its " + "round stands still until it answers." + ), + parameters={ + "type": "object", + "properties": { + "focus": { + "type": "string", + "description": ( + "Optional. The one thing you want the owner's " + "judgement on — a question, not a summary of the work: " + "it already reads the history. Omit it and the owner " + "states its own stance." + ), + }, + "timeout_s": { + "type": "integer", + "minimum": 1, + "description": ( + "Whole seconds the owner may run — at least 1, and a " + "value outside that is refused. When it is reached the " + "owner's turn is cancelled and an error is returned. " + f"Default {DEFAULT_TIMEOUT_S}." + ), + }, + "intent": { + "type": "string", + "description": ( + "One sentence on why the owner is being asked and what " + "the round needs from it; it is logged with the call." + ), + }, + }, + "required": ["intent"], + }, + ) + + async def execute(self, arguments: dict) -> ToolResult: + intent = arguments.get("intent") + if not isinstance(intent, str) or not intent.strip(): + return ToolResult( + name="boss", + content=( + "boss: `intent` is required and must not be empty — it is what " + "the log keeps of this call, and every tool call carries one." + ), + error=True, + ) + + focus = arguments.get("focus") + if focus is not None and not isinstance(focus, str): + return ToolResult( + name="boss", + content=( + f"boss: `focus` must be a string, so got {focus!r}. It is an " + "optional question, so leaving it out is how a caller states " + "'no particular question'." + ), + error=True, + ) + + timeout_s = arguments.get("timeout_s") + if timeout_s is None: + timeout_s = DEFAULT_TIMEOUT_S + if isinstance(timeout_s, bool) or not isinstance(timeout_s, int): + return ToolResult( + name="boss", + content=( + f"boss: `timeout_s` must be a whole number of seconds, so got " + f"{timeout_s!r}. It is the bound on the owner's run, and a " + "value from outside that domain is a caller error, not a " + "default." + ), + error=True, + ) + if timeout_s < 1: + return ToolResult( + name="boss", + content=( + f"boss: `timeout_s` must be at least 1, so got {timeout_s}. A " + "non-positive bound would cancel the owner before its first " + "round." + ), + error=True, + ) + + parent_session_id = arguments.get("_parent_session_id") or "" + parent_cwd = arguments.get("_parent_cwd") or "" + if not parent_session_id or not parent_cwd: + return ToolResult( + name="boss", + content=( + "boss: this call carries no parent session, so there is no " + "task for an owner to own. The daemon injects the parent's " + "session and cwd into every call it dispatches; a call without " + "them did not come from a daemon turn." + ), + error=True, + ) + + # The parent turn's tool set, injected by the daemon (#2465). A value that + # is not a list is "no statement", the same meaning the message field + # carries, so silence cannot be read two ways. + raw_parent_tools = arguments.get("_parent_tools") + parent_tools = ( + [str(name) for name in raw_parent_tools] + if isinstance(raw_parent_tools, list) else None + ) + + outcome = await self._runner( + parent_session_id=str(parent_session_id), + parent_cwd=str(parent_cwd), + focus=(focus or ""), + timeout_s=int(timeout_s), + intent=intent, + parent_tools=parent_tools, + ) + if outcome.get("error"): + return ToolResult(name="boss", content=outcome["error"], error=True) + return ToolResult(name="boss", content=outcome.get("text", "")) diff --git a/scripts/check-rant-citations.py b/scripts/check-rant-citations.py index ab712da8..af09c443 100644 --- a/scripts/check-rant-citations.py +++ b/scripts/check-rant-citations.py @@ -169,6 +169,7 @@ class reads **45 sites over 30 distinct timestamps in 9 of the 12 files**, and 4 "emrg/server/open_source_prompt.md", "emrg/server/promote_prompt.md", "emrg/server/paper_prompt.md", + "emrg/server/prompts/boss.j2", # rendered per `boss` call (#2467) "emrg/server/prompts/memory_compaction.j2", # rendered by daemon.COMPACTION_TEMPLATE "emrg/server/prompts/system.j2", "emrg/server/prompts/upgrade_prompt.j2", diff --git a/tests/test_boss_tool.py b/tests/test_boss_tool.py new file mode 100644 index 00000000..114b24cd --- /dev/null +++ b/tests/test_boss_tool.py @@ -0,0 +1,421 @@ +"""The owner role is a tool, and its session is a role held across rounds (issue #2467). + +Every scheduled task is one agent that decides the direction and does the work in +the same round. Two cheaper ways of separating those jobs were tried and both +failed: review semantics baked into the generic ``subagent`` schema (refused — a +general tool with a special purpose is no longer general), and "call a subagent +first and brief it thus" written into the templates (16% → 56% → 31% of rounds, so +the wording is not the lever). A parent-written brief is structurally wrong in any +case: a brief is a summary, and the position the owner exists to supply is exactly +what a summary drops. + +What is asserted here, and why each arm is a separate subject: + +* **the roster** — ``boss`` is registered, is offered to a scheduled round, and is + **not** offered to a host conversation. That last fact is the tool's whole + authorization story: it lives in one whitelist and nowhere else, so a second + mechanism inside the tool asking "who called me?" would be a copy of a fact the + producer already holds; +* **the arguments** — ``intent`` is required, ``focus`` is optional and is not a + brief, and every refusal names the parameter it refused; +* **where the mission comes from** — driven for real: the owner's first request + does **not** contain the mission and its second one does, which can only happen + if the owner read the parent's history itself. A test that asserted the prompt + names the path would pass on a design where the parent still supplied the text; +* **the directory** — created on first use, reused on the next call (a marker + written inside survives), and gone with the parent's ``sub-agent/``; +* **the templates** — all eight task templates carry the same sentence, with a + control arm showing the guard goes red when one is stripped, and ``boss.j2`` is + a file the host edits rather than a string in the code. + +No daemon is started, stopped or restarted: the runners are called directly, and +only the LLM, the broadcast and the memory reflection are stubbed. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest + +from emrg.config import LlmConfig +from emrg.server import daemon as daemon_mod +from emrg.server.daemon import ( + EmrgServer, + _get_jinja_env, + build_tool_registry, + default_tool_names, + scheduled_task_tool_names, +) +from emrg.session import Session +from emrg.tools.boss_tool import DEFAULT_TIMEOUT_S, BossTool + +REPO_ROOT = Path(__file__).resolve().parent.parent +PROMPTS = REPO_ROOT / "emrg" / "server" / "prompts" +TEMPLATES = ( + "evolution_prompt.md", + "paper_prompt.md", + "open_source_prompt.md", + "promote_prompt.md", + "journal_prompt.md", + "competition_prompt.md", + "ios_app_prompt.md", + "feishu_prompt.md", +) + +#: The sentence every task template must carry, verbatim. One string, so the guard +#: and the templates cannot drift apart by a word. +THE_ASK = "**The round's first step is to call `boss`**" + +#: A string that appears in the mission and nowhere else. What makes the "the owner +#: read it itself" arm possible: it can only reach the owner's second request +#: through the file. +MISSION_MARKER = "MISSION-ONLY-8f3a91c2" + + +# ── the roster ──────────────────────────────────────────────────────── + + +def test_boss_is_registered_and_offered_to_scheduled_rounds_alone(): + registry = build_tool_registry(None, None) + assert registry.get("boss") is not None, ( + "boss is in a whitelist but not registered — the whitelist and the " + "registration have drifted apart" + ) + assert "boss" in scheduled_task_tool_names(), ( + "the owner role is not offered to a scheduled round, so no task can ask for " + "one and the tool is dead weight" + ) + assert "boss" not in default_tool_names(), ( + "the owner role is offered to a host conversation: the split in the two " + "whitelists is the whole of its authorization, so this is a leak, not a " + "convenience" + ) + + +# ── the arguments ───────────────────────────────────────────────────── + + +class _Runner: + """Records what the tool handed the daemon, and answers with a stance.""" + + def __init__(self, answer: str = "a stance") -> None: + self.calls: list[dict] = [] + self._answer = answer + + async def __call__(self, **kwargs): + self.calls.append(kwargs) + return {"text": self._answer} + + +def _injected(**extra): + """The arguments the daemon injects, plus whatever a test adds.""" + args = { + "intent": "ask the owner where this round goes", + "_parent_session_id": "emrg-task", + "_parent_cwd": "/tmp/whatever", + } + args.update(extra) + return args + + +def test_the_owner_is_reached_with_the_injected_identity(): + runner = _Runner() + result = asyncio.run(BossTool(runner).execute(_injected())) + assert not result.error, result.content + assert result.content == "a stance" + call = runner.calls[0] + assert call["parent_session_id"] == "emrg-task" + assert call["parent_cwd"] == "/tmp/whatever" + assert call["timeout_s"] == DEFAULT_TIMEOUT_S + # No `prompt` is passed and none may be: the owner reads the mission itself, + # and a brief here is the thing this tool exists to replace. + assert "prompt" not in call + + +def test_a_call_without_intent_is_refused(): + runner = _Runner() + result = asyncio.run(BossTool(runner).execute({ + "_parent_session_id": "s", "_parent_cwd": "/tmp", + })) + assert result.error + assert "intent" in result.content + assert runner.calls == [], "the owner ran without the log keeping a reason" + + +def test_a_call_without_a_parent_session_is_refused(): + runner = _Runner() + result = asyncio.run(BossTool(runner).execute({"intent": "x"})) + assert result.error + assert "parent session" in result.content + assert runner.calls == [] + + +def test_focus_is_optional_and_must_be_a_string(): + runner = _Runner() + asyncio.run(BossTool(runner).execute(_injected())) + assert runner.calls[0]["focus"] == "", ( + "an absent focus must read as 'no particular question', not as a brief" + ) + + refused = asyncio.run(BossTool(_Runner()).execute(_injected(focus=["a", "b"]))) + assert refused.error + assert "focus" in refused.content + + +@pytest.mark.parametrize("bad", [True, "600", 0, -1, 1.5]) +def test_the_timeout_domain_is_refused(bad): + result = asyncio.run(BossTool(_Runner()).execute(_injected(timeout_s=bad))) + assert result.error, f"{bad!r} was accepted as a bound on the owner's run" + assert "timeout_s" in result.content + + +def test_an_error_from_the_runner_is_reported_as_an_error(): + class _Failing: + async def __call__(self, **kwargs): + return {"error": "boss: refused for a reason"} + + result = asyncio.run(BossTool(_Failing()).execute(_injected())) + assert result.error + assert "refused for a reason" in result.content + + +# ── the templates ───────────────────────────────────────────────────── + + +def _templates_missing_the_ask(texts: dict[str, str]) -> list[str]: + """Which of the eight task templates do not carry the sentence.""" + return sorted(name for name, text in texts.items() if THE_ASK not in text) + + +def _template_texts() -> dict[str, str]: + return { + name: (REPO_ROOT / "emrg" / "server" / name).read_text(encoding="utf-8") + for name in TEMPLATES + } + + +def test_every_task_template_asks_for_the_owner(): + missing = _templates_missing_the_ask(_template_texts()) + assert missing == [], ( + f"these templates never tell the round to ask its owner: {missing}" + ) + + +def test_the_template_guard_catches_a_stripped_sentence(): + """The control arm: a reading that is green whatever it is given measured nothing.""" + texts = _template_texts() + subject = "feishu_prompt.md" + texts[subject] = texts[subject].replace(THE_ASK, "") + assert _templates_missing_the_ask(texts) == [subject], ( + "the guard did not notice the sentence missing from one template, so the " + "green arm above says nothing about the other seven" + ) + + +def test_the_boss_template_is_a_file_the_host_edits(): + """No restart: the render reads the file, so an edit is live on the next call.""" + source = PROMPTS / "boss.j2" + assert source.is_file(), "boss.j2 must be a file in the prompts directory" + original = source.read_text(encoding="utf-8") + try: + rendered = _get_jinja_env().get_template("boss.j2").render( + parent_history="/tmp/session/history.jsonl", focus="", + ) + assert "/tmp/session/history.jsonl" in rendered, ( + "the template does not receive the path to the parent's history, so the " + "owner has no way to read the mission" + ) + + marker = "TEMPLATE-EDIT-IS-LIVE-4b7d" + source.write_text(original + "\n" + marker + "\n", encoding="utf-8") + again = _get_jinja_env().get_template("boss.j2").render( + parent_history="/tmp/session/history.jsonl", focus="", + ) + assert marker in again, ( + "an edit to boss.j2 did not reach the next render — the wording is the " + "host's, and a cached copy makes it the code's" + ) + finally: + source.write_text(original, encoding="utf-8") + + +# ── the session the owner runs in ───────────────────────────────────── + + +def _server(tmp_path, monkeypatch) -> EmrgServer: + monkeypatch.setattr(daemon_mod, "_PLANTED_FIRE_MARKER_PATH", tmp_path / "fire") + monkeypatch.setattr(daemon_mod, "_PLANTED_FIRE_ROUND_COMPLETE_PATH", tmp_path / "rc") + server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) + server._projects_log = tmp_path / "projects.yml" + server._maybe_reflect_memory = lambda *a, **k: None + return server + + +def _parent(tmp_path, session_id: str = "task-1") -> Session: + """A parent session whose history holds a mission nothing else carries.""" + parent = Session.create_with_id(session_id, tmp_path) + parent.append_message({ + "type": "message", "role": "user", + "content": f"{MISSION_MARKER} — do the task the host described.", + "timestamp": "2026-10-22T00:00:00", + }) + parent.append_message({ + "type": "message", "role": "assistant", "content": "round one done", + "timestamp": "2026-10-22T00:01:00", + }) + return parent + + +def _stub_the_loop(server: EmrgServer) -> list[dict]: + """Replace the owner's turn with a no-op that records the request it got.""" + seen: list[dict] = [] + + async def loop(req, ws, session, cancel_event, **kwargs): + seen.append({"prompt": req.prompt, "cwd": req.cwd, "session": session.session_id}) + + server._run_tool_loop_locked = loop + return seen + + +def _boss_cwd(tmp_path, session_id: str = "task-1") -> Path: + return ( + tmp_path / ".emrg" / "sessions" / session_id / "sub-agent" / "sub-boss" + ) + + +def _ask(server: EmrgServer, tmp_path, session_id: str = "task-1") -> dict: + return asyncio.run(server._run_boss_child( + parent_session_id=session_id, parent_cwd=str(tmp_path), focus="", + intent="ask", state={"child": None}, parent_tools=["read"], + )) + + +def test_the_owner_is_given_the_path_to_the_history_and_not_the_mission( + tmp_path, monkeypatch, +): + server = _server(tmp_path, monkeypatch) + _parent(tmp_path) + seen = _stub_the_loop(server) + + _ask(server, tmp_path) + + history = tmp_path / ".emrg" / "sessions" / "task-1" / "history.jsonl" + assert str(history) in seen[0]["prompt"], ( + "the owner was not told where the mission is, so it cannot read it" + ) + assert MISSION_MARKER not in seen[0]["prompt"], ( + "the mission reached the owner through the prompt: the executor is briefing " + "the owner again, which is the design this tool replaced" + ) + assert _boss_cwd(tmp_path).is_dir() + + +def test_the_owner_reads_the_mission_itself(tmp_path, monkeypatch): + """The real tool loop: the owner's second request carries the mission. + + That can only be true if the owner read the parent's history — the string + exists nowhere else — so this is the arm a prompt-shape assertion cannot reach. + """ + server = _server(tmp_path, monkeypatch) + _parent(tmp_path) + history = tmp_path / ".emrg" / "sessions" / "task-1" / "history.jsonl" + + seen: list[str] = [] + state = {"round": 0} + + async def fake_stream(messages, tools=None): + state["round"] += 1 + seen.append(json.dumps(messages, ensure_ascii=False)) + if state["round"] == 1: + yield { + "content": None, + "tool_calls": [{ + "index": 0, + "id": "call_0", + "function": { + "name": "read", + "arguments": json.dumps({ + "file_path": str(history), + "intent": "read the mission the task was given", + }), + }, + }], + "finish_reason": "tool_calls", + "usage": None, + } + else: + yield { + "content": "Aim at the mission; do not re-plan.", "tool_calls": None, + "finish_reason": "stop", + "usage": {"prompt_tokens": 1, "completion_tokens": 1}, + } + + async def fake_broadcast(session_id, payload): + pass + + server.llm.chat_stream = fake_stream + server._broadcast = fake_broadcast + + out = _ask(server, tmp_path) + + assert "Aim at the mission; do not re-plan." in out["text"], ( + f"the owner's answer did not reach the executor: {out!r}" + ) + assert MISSION_MARKER not in seen[0], ( + "the mission was in the owner's first request, which means the executor put " + "it there" + ) + assert MISSION_MARKER in seen[-1], ( + "the owner's second request does not carry the mission, so it never read the " + "parent's history — the one thing that separates this from a briefed subagent" + ) + + +def test_the_owner_session_is_reused_rather_than_rebuilt(tmp_path, monkeypatch): + server = _server(tmp_path, monkeypatch) + _parent(tmp_path) + _stub_the_loop(server) + + _ask(server, tmp_path) + marker = _boss_cwd(tmp_path) / ".emrg" / "sessions" / "boss-task-1" / "keep-me" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("survives", encoding="utf-8") + + _ask(server, tmp_path) + + assert marker.read_text(encoding="utf-8") == "survives", ( + "the owner's session was cleared between calls: the role is held across " + "rounds, and a cleared one re-reads the whole mission every round" + ) + + +def test_the_owner_goes_away_with_the_parent(tmp_path, monkeypatch): + server = _server(tmp_path, monkeypatch) + _parent(tmp_path) + _stub_the_loop(server) + + _ask(server, tmp_path) + assert _boss_cwd(tmp_path).is_dir() + + # The parent's own teardown, not a hand-rolled rmtree: this is what a deleted + # session does, and the owner's directory sits under it. + Session.delete("task-1", tmp_path) + assert not _boss_cwd(tmp_path).exists(), ( + "the owner's directory outlived the session it belongs to" + ) + + +def test_the_depth_limit_bounds_an_owner_that_asks_for_an_owner(tmp_path, monkeypatch): + server = _server(tmp_path, monkeypatch) + parent = _parent(tmp_path) + parent.set_depth(server.SUBAGENT_MAX_DEPTH) + seen = _stub_the_loop(server) + + out = _ask(server, tmp_path) + + assert out.get("error"), "an owner was started past the delegation depth limit" + assert "delegations deep" in out["error"] + assert seen == [], "the refused call still ran a turn" diff --git a/tests/test_tool_argument_domains.py b/tests/test_tool_argument_domains.py index 82932826..6e6b58a4 100644 --- a/tests/test_tool_argument_domains.py +++ b/tests/test_tool_argument_domains.py @@ -41,6 +41,7 @@ from emrg.server import scheduler from emrg.tools import ( bash_tool_v2, + boss_tool, edit_tool, glob_tool, grep_tool, @@ -209,6 +210,7 @@ def test_a_positive_shell_timeout_still_runs(tmp_path): (bash_tool_v2.BashToolV2(), "timeout", ("positive",)), (pwsh_tool_v2.PwshToolV2(), "timeout", ("positive",)), (subagent_tool.SubagentTool(lambda **kwargs: None), "timeout_s", ("at least 1",)), + (boss_tool.BossTool(lambda **kwargs: None), "timeout_s", ("at least 1",)), ] @@ -408,6 +410,7 @@ def test_a_description_claim_nothing_reads_is_the_class_this_file_closes(): # The runner is never called: this sweep reads `definition()` only, and the tool # is here so its declared parameters are asked about like every other tool's. subagent_tool.SubagentTool(lambda **kwargs: None), + boss_tool.BossTool(lambda **kwargs: None), ] diff --git a/tests/test_tool_set_per_message.py b/tests/test_tool_set_per_message.py index 3af0b95c..a35cd492 100644 --- a/tests/test_tool_set_per_message.py +++ b/tests/test_tool_set_per_message.py @@ -540,7 +540,20 @@ def test_the_scheduler_states_the_scheduled_task_set(tmp_path, monkeypatch): # Kept as a pointer to the assertion's home: `_run_cycle_bounded` needs a # websocket, a handler and a template, and test_scheduler.py already builds # all three for the neighbouring field (`input_history`). - assert scheduled_task_tool_names() == default_tool_names(), ( - "today the two sets are equal; when they stop being equal, the assertion in " - "test_scheduler.py is what has to change with them" + # + # The two sets were equal until #2467 gave scheduled rounds a tool a + # conversation must not have. What is asserted here is the **shape** of that + # difference rather than its contents: an equality against a literal would + # restate the implementation and go green on any change that kept the restatement + # in step, while the property below is the one that matters — the difference is + # exactly the owner role, and no tool is lost from either side. + scheduled = set(scheduled_task_tool_names()) + default = set(default_tool_names()) + assert scheduled - default == {"boss"}, ( + "the two sets differ by the owner role and nothing else (#2467); any other " + f"difference is a tool that silently appeared on one side: {scheduled - default}" + ) + assert not default - scheduled, ( + "a tool offered to a conversation must stay available to a scheduled round; " + f"these were dropped: {default - scheduled}" )