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
9 changes: 9 additions & 0 deletions emrg/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ class TaskRequest:
# danger-full-access, current behavior). Set by task config, never by
# the agent itself.
sandbox: Optional[str] = None
# The tools this turn may use, by name (issue #2465). `None` means "the
# daemon's default set"; a list is a whitelist, intersected with the tools
# actually registered, so a name that is not registered is dropped rather
# than refused. An **empty** list is meaningful — it asks for no tools at
# all — which is why both the sender and the reader test `is not None`
# rather than the truthiness `sandbox` above is tested for.
tools: Optional[list[str]] = None

def to_dict(self) -> dict:
d = {
Expand All @@ -59,6 +66,8 @@ def to_dict(self) -> dict:
d["images"] = self.images
if self.sandbox:
d["sandbox"] = self.sandbox
if self.tools is not None:
d["tools"] = self.tools
return d


Expand Down
164 changes: 148 additions & 16 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,72 @@ def resolve_client_tier(session, req_sandbox: str | None = None) -> str:
#: roots — and two hand-written unions would be two chances to drift apart.
_ROOT_CONSUMING_TOOLS = SHELL_TOOL_NAMES | {"write", "edit"}

#: The tools a **host conversation** is offered, by name (issue #2465).
#:
#: This is a whitelist, and naming a tool is the only way to offer it: a tool
#: registered in ``build_tool_registry`` but named in neither this list nor
#: ``scheduled_task_tool_names()`` is invisible to every session. That is the
#: point — some tools are meaningful for one kind of turn and not another — but
#: it makes adding a tool a two-part act, and the second part is here.
#:
#: The shell entry is the platform's dialect rather than the word ``"bash"``: on
#: Windows the registered tool is ``pwsh``, so a list spelled ``"bash"`` would be
#: intersected away and the session would lose its shell silently.
#:
#: The platform is a parameter for the same reason it is one in
#: ``shell_tool_name``: the coupling that matters is that this set and the
#: registry it is intersected with were built for **one** platform, and a fact the
#: Windows CI leg alone can reach is a fact no host can check. Stating the platform
#: makes the pair askable about either dialect on any host.
def default_tool_names(platform_name: str | None = None) -> list[str]:
"""The tool names a host conversation may use (issue #2465)."""
return [
shell_tool_name(platform_name), "read", "write", "edit", "glob", "grep",
"submit_rant", "subagent",
]


def scheduled_task_tool_names(platform_name: str | None = None) -> list[str]:
"""The tool names a scheduled task's round may use (issue #2465).

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?".
"""
return default_tool_names(platform_name)


def effective_tool_names(
registry: ToolRegistry, requested: list[str] | None,
platform_name: str | None = None,
) -> list[str]:
"""``(requested or the default set) ∩ registered``, in the caller's order.

Intersection, not validation (issue #2465): a name that is not registered is
dropped in silence rather than refused, because a message naming a tool this
build does not carry is a message from a different build, and failing the
whole turn over it would make one stale name cost the round. Duplicates
collapse to their first occurrence, so the set cannot be handed the same tool
twice.

``platform_name`` is the platform ``registry`` was built for; it reaches
``default_tool_names`` so that this intersection can be asked the question the
Windows runner asks, on any host.
"""
names = (
default_tool_names(platform_name) if requested is None
else list(requested)
)
seen: set[str] = set()
out: list[str] = []
for name in names:
if name in seen:
continue
seen.add(name)
if registry.get(name) is not None:
out.append(name)
return out

#: How much of a child session's final answer a `subagent` call returns, in
#: characters (issue #2451). The answer is the one thing the parent gets back, so it
#: needs a ceiling — a child that dumps a whole file would spend the parent's context
Expand Down Expand Up @@ -1649,6 +1715,11 @@ async def _handle_client(self, ws) -> None:
timestamp=data.get("timestamp", ""),
images=data.get("images"),
sandbox=data.get("sandbox"),
# Issue #2465: which tools this turn may use, as the
# producer stated it. Absent (`None`) means the
# default set — which is what every client that does
# not care sends, and what a host conversation gets.
tools=data.get("tools"),
)
except Exception as e:
await self._send(ws, {"error": f"invalid task: {e}"})
Expand Down Expand Up @@ -4167,7 +4238,10 @@ async def _run_tool_loop_locked(
})

@staticmethod
def _inject_tool_arguments(tc_name: str, args: dict, session, req: TaskRequest) -> None:
def _inject_tool_arguments(
tc_name: str, args: dict, session, req: TaskRequest,
tool_names: list[str] | None = None,
) -> None:
"""Inject the parts of a tool call the model must not choose.

Three rules. The first two are one fact — the session's cwd is where a
Expand Down Expand Up @@ -4275,6 +4349,13 @@ def _inject_tool_arguments(tc_name: str, args: dict, session, req: TaskRequest)
# 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
# is the same authorisation seen one level down, so a round that may
# not use a tool cannot hand it to a session it spawns — otherwise
# `subagent` would be a way around the set rather than a use of it.
args["_parent_tools"] = (
list(tool_names) if tool_names is not None else None
)

async def _apply_escalation(
self, tc_name: str, args: dict, session, req: TaskRequest,
Expand Down Expand Up @@ -4416,7 +4497,15 @@ async def _run_tool_loop(
# The message was just built and persisted above; the same one goes in,
# so the stored bytes are the sent bytes (issue #2311).
self._inject_context_message(session, messages, context_message)
tools_base = self.tools.to_openai_tools() if allow_tools else []
# Issue #2465: the tools this turn may actually use. Computed once and
# read by all three enforcement points — the table the model is offered,
# the lookup that runs a named tool, and the memory-reflection loop —
# because a filter at only one of them is a gate in name only.
turn_tool_names = effective_tool_names(self.tools, req.tools)
turn_tool_set = set(turn_tool_names)
tools_base = (
self.tools.to_openai_tools(turn_tool_names) if allow_tools else []
)
# P1 (rant 21:55:37): injection rounds do NOT consume the round budget;
# force_ask latches "an Ask message was injected outside the round-top
# injection (stop / Case 3 / loop-end)" so the next round uses an empty
Expand Down Expand Up @@ -4472,7 +4561,9 @@ async def _run_tool_loop(
})

# Fire-and-forget: reflect on whether to save memories
self._maybe_reflect_memory(session, req.prompt, full_content)
self._maybe_reflect_memory(
session, req.prompt, full_content, turn_tool_names,
)
return

tools_openai = tools_base
Expand Down Expand Up @@ -4906,7 +4997,9 @@ async def _run_tool_loop(
})

# Fire-and-forget: reflect on whether to save memories
self._maybe_reflect_memory(session, req.prompt, full_content)
self._maybe_reflect_memory(
session, req.prompt, full_content, turn_tool_names,
)
# Issue #1114: a round GENUINELY completed here (final text
# answer, no more tool calls) — stamp the completed-round file
# so the staleness alarm can answer "how long since a
Expand Down Expand Up @@ -5002,13 +5095,26 @@ async def _run_tool_loop(
})

# The pieces of a call the model must not choose (D1).
self._inject_tool_arguments(tc_name, args, session, req)
self._inject_tool_arguments(
tc_name, args, session, req, turn_tool_names,
)

# Issue #2465: a name outside this turn's set is answered
# exactly as a name that was never registered — the refusal
# below, not a second vocabulary for saying no. Decided
# **before** the escalation path, because escalation is a
# question put to the host: a call that cannot run must not
# ask permission to run first, and an answer of "yes" would
# then be reported as the call's outcome although the tool it
# widened for was never dispatched.
tool = (
self.tools.get(tc_name) if tc_name in turn_tool_set else None
)
# Execute
refusal = await self._apply_escalation(
tc_name, args, session, req,
refusal = (
await self._apply_escalation(tc_name, args, session, req)
if tool is not None else None
)
tool = self.tools.get(tc_name)
if refusal is not None:
# A refused escalation is the call's outcome, and the
# command does not run at all: a hop that was not granted
Expand Down Expand Up @@ -5036,7 +5142,9 @@ async def _run_tool_loop(
result = ToolResult(
tool_call_id=tc_id,
name=tc_name,
content=f"Unknown tool: {tc_name}. Available: {self.tools.names}",
content=(
f"Unknown tool: {tc_name}. Available: {turn_tool_names}"
),
error=True,
)

Expand Down Expand Up @@ -5123,7 +5231,9 @@ async def _run_tool_loop(
})

# Fire-and-forget: reflect on whether to save memories
self._maybe_reflect_memory(session, req.prompt, full_content)
self._maybe_reflect_memory(
session, req.prompt, full_content, turn_tool_names,
)
# Issue #1114: max-tokens/other-stop with the final answer
# persisted + broadcast = a genuinely completed round — stamp the
# completed-round file (see Case 1; NOT done on the cancellation /
Expand Down Expand Up @@ -7490,7 +7600,7 @@ async def _await_memory_reflection(self, session_id: str) -> None:

async def _run_subagent_tool(
self, *, parent_session_id: str, parent_cwd: str, prompt: str,
timeout_s: int, intent: str = "",
timeout_s: int, intent: str = "", parent_tools: list[str] | None = None,
) -> dict:
"""Run one child session for one `subagent` call. Answers text or an error.

Expand All @@ -7514,6 +7624,7 @@ async def _run_subagent_tool(
prompt=prompt,
intent=intent,
state=state,
parent_tools=parent_tools,
))
try:
done, _ = await asyncio.wait({child_task}, timeout=timeout_s)
Expand Down Expand Up @@ -7544,7 +7655,7 @@ async def _run_subagent_tool(

async def _run_subagent_child(
self, *, parent_session_id: str, parent_cwd: str, prompt: str,
intent: str, state: dict,
intent: str, state: dict, parent_tools: list[str] | None = None,
) -> dict:
"""The body of one delegation: pick a slot, run the child, report its answer."""
started = time.monotonic()
Expand Down Expand Up @@ -7616,6 +7727,10 @@ async def _run_subagent_child(
prompt=prompt,
timestamp="",
sandbox=parent.sandbox,
# Issue #2465: the child's tool set is the parent's — inherited
# the same way its tier is, because authorisation a caller can
# choose for the thing it is authorising is not a boundary.
tools=parent_tools,
)
assistants_before = sum(
1 for r in child._read_history() if r.get("role") == "assistant"
Expand Down Expand Up @@ -7685,7 +7800,8 @@ def _subagent_report(
)

def _maybe_reflect_memory(
self, session: Session, user_prompt: str, assistant_content: str
self, session: Session, user_prompt: str, assistant_content: str,
tool_names: list[str] | None = None,
) -> None:
"""Fire-and-forget: ask LLM to reflect on the exchange and save memories.

Expand Down Expand Up @@ -7818,8 +7934,21 @@ async def _reflect():
"directory to keep the index in sync."
)

# Run a mini tool loop: LLM can create/update memory files via tools
tools = self.tools.to_openai_tools()
# Run a mini tool loop: LLM can create/update memory files via tools.
# Issue #2465: this loop builds its **own** table, so a filter in
# `_run_tool_loop` alone would leave this copy offering every
# registered tool — the third enforcement point, not a duplicate.
#
# `None` is "the caller stated no set", and the answer to that is
# the **default set**, not the whole registry. Answering the same
# silence two ways is a gate with a hole in it: a tool registered
# but named in neither whitelist (the review/owner role this issue
# leaves room for) would be offered here while every other site
# refuses it. One silence, one meaning — and the meaning is the
# function that already states it.
reflect_names = effective_tool_names(self.tools, tool_names)
tools = self.tools.to_openai_tools(reflect_names)
reflect_tool_set = set(reflect_names)
messages: list[dict] = [{"role": "user", "content": prompt}]
max_rounds = 4

Expand Down Expand Up @@ -7880,7 +8009,10 @@ async def _reflect():
except json.JSONDecodeError:
args = {}

tool = self.tools.get(tc_name)
tool = (
self.tools.get(tc_name)
if tc_name in reflect_tool_set else None
)
if tool:
try:
result = await tool.execute(args)
Expand Down
11 changes: 11 additions & 0 deletions emrg/server/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2678,6 +2678,12 @@ async def _run_evolution_cycle(self) -> str:
# silent ending (see `_end_heartbeat`).
return self._NOT_STARTED

# Issue #2465: this round's tool set is the **scheduled-task** one.
# Imported here rather than at module scope because `emrg.server.daemon`
# imports this module; the whitelists live there, beside the registration
# they describe, so there is one place that says what a tool set is.
from emrg.server.daemon import scheduled_task_tool_names

task_msg = json.dumps(
{
"type": "task",
Expand All @@ -2704,6 +2710,11 @@ async def _run_evolution_cycle(self) -> str:
# forces, not what dirt does. `EMRG_TASK_DIRTY_OVERRIDE` (audited
# receipt) skips that convergence rather than a downgrade.
"sandbox": await self._effective_sandbox(),
# Issue #2465: which tools this round may use. Named by the
# producer because only the producer knows this is a scheduled
# round rather than something the host typed — a fact the daemon
# would otherwise have to guess from the session's name.
"tools": scheduled_task_tool_names(),
},
ensure_ascii=False,
)
Expand Down
13 changes: 11 additions & 2 deletions emrg/tools/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,25 @@ def get(self, name: str) -> Optional[ToolExecutor]:
"""Look up a tool by name. Returns None if not found."""
return self._tools.get(name)

def to_openai_tools(self) -> list[dict]:
"""Serialize all registered tools to OpenAI function-calling format.
def to_openai_tools(self, names: list[str] | None = None) -> list[dict]:
"""Serialize registered tools to OpenAI function-calling format.

:param names: the only tool names to serialize, in registry order.
``None`` (the default) serializes the whole registry, which is what
a caller that is not narrowing a turn's set wants. A name this
registry does not hold is simply not there — the list narrows, it
does not create (issue #2465).

Returns a list of dicts suitable for the `tools` field
of a chat completions API request:
[{"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}}]
"""
allowed = None if names is None else set(names)
result: list[dict] = []
for tool in self._tools.values():
d = tool.definition()
if allowed is not None and d.name not in allowed:
continue
result.append({
"type": "function",
"function": {
Expand Down
16 changes: 14 additions & 2 deletions emrg/tools/subagent_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@
from emrg.tools.base import ToolExecutor

#: The runner the daemon supplies: ``await runner(parent_session_id=…,
#: parent_cwd=…, prompt=…, timeout_s=…, intent=…)`` answering either
#: ``{"text": …}`` or ``{"error": …}``.
#: parent_cwd=…, prompt=…, timeout_s=…, intent=…, parent_tools=…)`` answering
#: either ``{"text": …}`` or ``{"error": …}``. ``parent_tools`` is the parent
#: turn's effective tool set (issue #2465), which the child inherits.
Runner = Callable[..., Awaitable[dict]]

#: A default that is generous enough for a real job and short enough that a
Expand Down Expand Up @@ -165,12 +166,23 @@ async def execute(self, arguments: dict) -> ToolResult:
error=True,
)

# The parent turn's tool set, injected by the daemon (issue #2465). A
# value that is not a list is treated as "no statement", which the
# daemon reads as its default set — the same meaning the message field
# has, so the two cannot disagree about silence.
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),
prompt=prompt,
timeout_s=int(timeout_s),
intent=str(arguments.get("intent") or ""),
parent_tools=parent_tools,
)
if outcome.get("error"):
return ToolResult(name="subagent", content=outcome["error"], error=True)
Expand Down
Loading
Loading