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
78 changes: 63 additions & 15 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
import sys
import time
import traceback
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Optional
Expand Down Expand Up @@ -169,7 +169,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.tools.boss_tool import BUSY_MARKER, REASK_WINDOW_S, 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
Expand Down Expand Up @@ -8027,7 +8027,7 @@ def _subagent_report(
BOSS_DIR = "sub-boss"

async def _run_boss_tool(
self, *, parent_session_id: str, parent_cwd: str, focus: str = "",
self, *, parent_session_id: str, parent_cwd: str,
timeout_s: int, intent: str = "", parent_tools: list[str] | None = None,
parent_sandbox: str | None = None,
) -> dict:
Expand All @@ -8046,7 +8046,6 @@ async def _run_boss_tool(
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,
Expand All @@ -8071,12 +8070,12 @@ async def _run_boss_tool(
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."
f"{state['child']}. Raise `timeout_s` if the owner needs longer to read "
"this session's history."
)}

async def _run_boss_child(
self, *, parent_session_id: str, parent_cwd: str, focus: str,
self, *, parent_session_id: str, parent_cwd: str,
intent: str, state: dict, parent_tools: list[str] | None = None,
parent_sandbox: str | None = None,
) -> dict:
Expand Down Expand Up @@ -8109,19 +8108,39 @@ async def _run_boss_child(
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)

# The quiet window (issue #2495). Direction does not change inside four
# hours, so a second call in that time would spend a whole owner turn to
# re-buy a judgement that cannot have moved. The clock is the owner
# session's own `meta.json`, so a restart neither resets nor forgets it,
# and it is read before anything is started: no turn, no prompt, no depth.
asked_at = time.time()
last_asked = child.boss_asked_at
if last_asked is not None and asked_at - last_asked < REASK_WINDOW_S:
free_at = last_asked + REASK_WINDOW_S
logger.info(
"[boss] busy: parent=%s child=%s remaining=%.0fs",
parent_session_id, child_id, free_at - asked_at,
)
return {"text": self._boss_busy_report(
child_id, last_asked, free_at, free_at - asked_at,
)}

# Stamped before the turn runs, never after it: a turn that dies mid-way
# must still leave the clock written, or the next call pays for the same
# judgement again with nothing to show for the first.
child.set_boss_asked_at(asked_at)

prompt = _get_jinja_env().get_template("boss.j2").render(
parent_history=str(parent_history),
parent_session=parent_session_id,
)

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.
Expand Down Expand Up @@ -8218,6 +8237,35 @@ def _boss_report(
f"{answer}"
)

@staticmethod
def _boss_busy_report(
child_id: str, last_asked: float, free_at: float, remaining: float,
) -> str:
"""The answer a call inside the quiet window gets: a notice, and no stance.

An ordinary result rather than an error, because a busy owner is not a
failure of the round that asked — and its first line carries `BUSY_MARKER`
so that "the owner was left alone" is readable from the answer's shape.
The executor is told in as many words that no direction was decided, since
the tool otherwise promises every answer is a stance.
"""
def _stamp(when: float) -> str:
return datetime.fromtimestamp(when, tz=timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)

return (
f"{BUSY_MARKER} — the owner was left alone; this is not a stance.\n"
f"- owner session: {child_id}\n"
f"- last asked: {_stamp(last_asked)}\n"
f"- free at: {_stamp(free_at)}\n"
f"- remaining: {int(remaining)}s\n\n"
"No question was put to the owner this round, so no direction was "
"decided: carry on with the plan you already have, or finish and "
"report. Ask again once the window has passed if the direction "
"genuinely has to change."
)

def _maybe_reflect_memory(
self, session: Session, user_prompt: str, assistant_content: str,
tool_names: list[str] | None = None,
Expand Down
4 changes: 0 additions & 4 deletions emrg/server/prompts/boss.j2
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,4 @@ 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 %}
45 changes: 45 additions & 0 deletions emrg/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,14 @@ def __init__(self, session_id: str, cwd: Path) -> None:
# `subagent` call reads this, and a daemon restart must not reset the
# count of a session that already exists on disk.
self._depth: int = 0
# When this session — acting as an owner — was last asked for a stance, in
# seconds since the epoch (issue #2495). An owner is left alone for
# `REASK_WINDOW_S` after a call, because direction does not change inside
# four hours and a second call buys the same judgement again. It lives in
# `meta.json` for the same reason `depth` does: the session is retained on
# disk, so a daemon restart that reset this clock would let the very next
# round re-buy a decision it already paid for.
self._boss_asked_at: float | None = None

# Lazy-initialized memory store
self._memory_store = None
Expand Down Expand Up @@ -524,6 +532,15 @@ def load(cls, session_id: str, cwd: Path) -> Session:
# a JSON `true` in this field would then read as depth 1.
if isinstance(stored_depth, int) and not isinstance(stored_depth, bool):
session._depth = max(0, stored_depth)
stored_boss_asked_at = meta.get("boss_asked_at")
# The same care as `depth`: `True` is an int, and a JSON `true` here
# would read as the epoch's first second. Absent, malformed and
# non-numeric all mean "never asked", which is the state that permits
# a call — the safe direction for a value that only ever blocks one.
if isinstance(stored_boss_asked_at, (int, float)) and not isinstance(
stored_boss_asked_at, bool
):
session._boss_asked_at = float(stored_boss_asked_at)
logger.info("session loaded: %s (%d messages)", session_id, session._message_count)
return session

Expand Down Expand Up @@ -592,6 +609,30 @@ def set_depth(self, depth: int) -> None:
self._save_meta()
logger.info("session delegation depth set: %s -> %d", self.session_id, self._depth)

# ── The owner's clock ─────────────────────────────────────

@property
def boss_asked_at(self) -> float | None:
"""When this session was last asked for a stance, or ``None`` if never.

Seconds since the epoch, so the quiet window's comparison is arithmetic
rather than a parse: a malformed instant would otherwise have to be told
apart from an absent one at the point where it decides something.
"""
return self._boss_asked_at

def set_boss_asked_at(self, when: float) -> None:
"""Persist the instant this session was last asked, into ``meta.json``.

The only writer of the key, and it is called *before* the owner's turn
runs rather than after it: a stamp written on completion leaves a turn that
died looking like one that never happened, and the next call would then pay
for the same judgement a second time.
"""
self._boss_asked_at = float(when)
self._save_meta()
logger.info("session owner clock set: %s -> %.0f", self.session_id, self._boss_asked_at)

# ── Sandbox tier ──────────────────────────────────────────

@property
Expand Down Expand Up @@ -1098,6 +1139,10 @@ def _save_meta_with_title(self, title: str | None) -> None:
# key saying it is zero deep.
if self._depth:
meta["depth"] = self._depth
# Same rule again: a session that was never asked for a stance must not
# gain a key claiming one was.
if self._boss_asked_at is not None:
meta["boss_asked_at"] = self._boss_asked_at
if title is not None:
meta["title"] = title
else:
Expand Down
57 changes: 28 additions & 29 deletions emrg/tools/boss_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,15 @@
``<parent session dir>/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.
no steering text at all, not even a question: naming what the owner should judge is
choosing the direction, and a session that chose the question would be deciding how
its own work is judged. What the caller supplies is ``intent`` — the one-line reason
the log keeps — and nothing else.

**It is left alone between calls.** Direction does not change inside four hours, so
a call in that window starts no turn and buys no judgement; it answers with
``BUSY_MARKER`` and the time the owner frees up, which is an ordinary result rather
than an error because a busy owner is not a failure of the round that asked.

**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
Expand Down Expand Up @@ -47,15 +53,27 @@
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.
#: parent_cwd=…, timeout_s=…, intent=…, parent_tools=…, parent_sandbox=…)``
#: 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

#: How long an owner is left alone after a call (issue #2495). Direction does not
#: change inside four hours, so a second call in that window spends a full owner
#: turn to re-buy a judgement that cannot have changed. The constant lives here,
#: beside the description that tells the caller about it, so the advertised window
#: and the enforced one are one number: the daemon reads this same constant.
REASK_WINDOW_S = 4 * 60 * 60

#: What a busy answer opens with, so that "the owner was left alone" is readable
#: from the answer's shape rather than by running the prose through a reader. A
#: stance never begins with it.
BUSY_MARKER = "boss-busy-window"


class BossTool(ToolExecutor):
"""Ask this task's owner session what the coming rounds' stance is."""
Expand All @@ -75,20 +93,14 @@ def definition(self) -> ToolDefinition:
"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."
"round stands still until it answers. It is also left alone for "
f"{REASK_WINDOW_S // 3600}h after a call: an answer that opens with "
f"`{BUSY_MARKER}` is that notice, carries no stance, and means carry "
"on with your own round rather than waiting."
),
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,
Expand Down Expand Up @@ -123,18 +135,6 @@ async def execute(self, arguments: dict) -> ToolResult:
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
Expand Down Expand Up @@ -195,7 +195,6 @@ async def execute(self, arguments: dict) -> ToolResult:
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,
Expand Down
Loading
Loading