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
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,12 @@ caching:
synthesis never get them; the chat loop adds them per turn like the Act
as me tools, never on an unattended run, a turn private to the
principal or to a speaker not on the People list, and only where the
tool's offered(session) says. That
tool's offered(session) says. known_caller=False lifts the People-list
rule for that tool alone (an extension surface that names its user
without proving who is speaking, so the turn has no caller_person_id);
register_tool then requires an offered(), which decides alone. Never on
an unattended, private, AI-app (mcp_client) or replay turn either way.
That
predicate must depend only on the kind of turn, so each kind keeps one
cached tool list. Act as me's lockdown refuses them after a mail read
(unclassified, fails closed), except one registered with
Expand All @@ -354,6 +359,12 @@ caching:
existing (tool_groups.add_to_group, done at load, so the groups are
fixed per process). Its chip goes through summarize_action's usual
no-chip rules, and a chip link must be a path inside the app. A
waiting_for_approval result is the extension chip's to describe
(summarize_action skips its generic held-action chip for an extension
tool: the department gate never holds one). activity_label names the
chat's activity line while it runs (activity_labels._extension_label,
after the built-in labels), and private_row makes its dispatch audit row
private (executive._private_tool_row). A
collection is a drafted artifact tagged collection:<name>: the record
carries it, GET /artifacts returns collection / collection_label, the
Documents page shows one filter per collection, and archive, restore and
Expand Down

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion packages/core/openexecutive/orchestrator/action_chips.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,12 @@ def summarize_action(
parsed is not None
and parsed.get("status") == "waiting_for_approval"
and tool_name not in ("propose_actions", "do_in_app")
and not extension
):
# Held for someone's approval (a team member's send or change, the
# department gate): nothing happened yet, so no "done" chip.
# department gate): nothing happened yet, so no "done" chip. An
# extension's tool is never held there; what it waits on is its
# chip's to describe.
payload["summary"] = "Waiting for approval"
return payload
if extension:
Expand Down
15 changes: 13 additions & 2 deletions packages/core/openexecutive/orchestrator/activity_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,22 @@ def _label_for(tool_use: dict[str, Any]) -> tuple[str, str]:
return FALLBACK_LABEL, ""
if name == "call_tool":
return _mcp_label(tool_use.get("input"))
return _LABELS.get(name, FALLBACK_LABEL), name
return _LABELS.get(name) or _extension_label(name) or FALLBACK_LABEL, name


def _extension_label(name: Any) -> str | None:
"""The activity line an installed extension's tool registered
(orchestrator/extensions.py), if any."""
if not isinstance(name, str) or name in _LABELS:
return None
from openexecutive.orchestrator import extensions

return extensions.activity_label(name)
Comment thread
johnrufusone marked this conversation as resolved.


def _is_nameable(tool_use: dict[str, Any]) -> bool:
return tool_use.get("name") in _LABELS or tool_use.get("name") == "call_tool"
name = tool_use.get("name")
return name in _LABELS or name == "call_tool" or _extension_label(name) is not None


def _pick(tool_uses: list[dict[str, Any]]) -> dict[str, Any]:
Expand Down
3 changes: 3 additions & 0 deletions packages/core/openexecutive/orchestrator/executive.py
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,9 @@ def _private_tool_row(tool_name: str) -> bool:
tool_name in DELEGATION_TOOL_NAMES
or tool_name in FACT_TOOL_HANDLERS
or tool_name in HISTORY_TOOL_NAMES
# An installed extension's tool that quotes the speaker's own data
# (orchestrator/extensions.py, private_row).
or extensions.private_row(tool_name)
)


Expand Down
52 changes: 44 additions & 8 deletions packages/core/openexecutive/orchestrator/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
``_ALL_SKILL_TOOLS``, which never holds these;
- never on a turn private to the principal (mail from one of their contacts);
- never to a speaker who isn't on the People list (mail from a stranger, an
unknown chat user), so text from outside the team can't reach these tools;
unknown chat user), so text from outside the team can't reach these tools.
A tool registered with ``known_caller=False`` is the one exception: it is
asked on a turn with no verified caller too (a surface that names its user
without proving who is speaking), and its own ``offered`` decides alone, so
it must have one that checks who is asking;
- like every tool Act as me's lockdown has not classified, it is refused once
the turn has read the owner's mail (``delegation.lockdown`` fails closed),
unless it is registered with ``after_mail_read=True``: a tool that only
Expand All @@ -37,7 +41,9 @@
reads (the workflow review card's "may change things" badge).

A tool may sit in a ``tool_groups`` group (opened with open_tools) instead of
on the direct list, and may describe its chip. A collection is a kind of
on the direct list, may describe its chip, may name the activity line the chat
shows while it runs, and may keep its dispatch audit row private (``private_row``:
its input or result quotes the speaker's own data). A collection is a kind of
document an extension publishes as a drafted artifact tagged
``collection_tag(name)``: the Documents page gives it its own filter, and the
extension hears when one is archived, restored or deleted, before it happens.
Expand Down Expand Up @@ -90,7 +96,9 @@ class ExtraTool:
offered: Callable[[Any], bool] | None = None
# The chip for a successful call: (tool input, parsed JSON result or
# None) -> {"summary", "target", "link"} or None for no chip. A link must
# be a path inside the app ("/artifacts/alert:12").
# be a path inside the app ("/artifacts/alert:12"). It is asked whatever
# the result's status, a "waiting_for_approval" one included: what an
# extension's tool waits on is its own, never core's held action.
chip: Callable[[dict[str, Any], dict[str, Any] | None], dict[str, Any] | None] | None = None
# Also offered to workflow action steps (see module doc), and whether it
# only reads there (None: may change something).
Expand All @@ -103,6 +111,14 @@ class ExtraTool:
# Returns text from outside the team, e.g. a vendor app's records (see
# module doc and ``orchestrator.outside_content``).
reads_outside: bool = False
# Asked on a turn with no verified caller too (see module doc); needs
# ``offered``.
known_caller: bool = True
# Its dispatch audit row is private: its input or result quotes the
# speaker's own data.
private_row: bool = False
# The chat's activity line while it runs ("Checking the draft…").
activity_label: str | None = None

@property
def name(self) -> str:
Expand Down Expand Up @@ -152,6 +168,8 @@ def register_tool(tool: ExtraTool) -> None:
raise ExtensionError(f"workflow tool name {name!r} must be at most {_MAX_WORKFLOW_NAME} characters")
if tool.after_mail_read and tool.read_only is not True:
raise ExtensionError(f"tool {name!r} may run after a mail read only if it is read_only")
if not tool.known_caller and tool.offered is None:
raise ExtensionError(f"tool {name!r} asked without a known caller needs its own offered()")
if tool.group is not None:
from openexecutive.orchestrator import tool_groups

Expand Down Expand Up @@ -226,14 +244,15 @@ def _forget_tool(name: str) -> None:

def offered_tools(session: Any) -> list[ExtraTool]:
"""The extension tools a turn on ``session`` is offered (the caller has
already excluded unattended and private turns): none unless the speaker
is on the People list, then each whose ``offered`` agrees. A failing
``offered`` counts as not offered."""
if not isinstance(getattr(session, "caller_person_id", None), int):
return []
already excluded unattended and private turns): unless the speaker is on
the People list, only those registered ``known_caller=False``; then each
whose ``offered`` agrees. A failing ``offered`` counts as not offered."""
known = isinstance(getattr(session, "caller_person_id", None), int)
load()
offered: list[ExtraTool] = []
for tool in _tools.values():
if not known and tool.known_caller:
continue
if tool.offered is not None:
try:
if not tool.offered(session):
Expand Down Expand Up @@ -262,6 +281,21 @@ def reads_outside(tool_name: str) -> bool:
return tool is not None and tool.reads_outside


def private_row(tool_name: str) -> bool:
"""Whether extension tool ``tool_name``'s dispatch audit row is private
(``private_row``). False for anything else."""
load()
tool = _tools.get(tool_name)
return tool is not None and tool.private_row


def activity_label(tool_name: str) -> str | None:
"""The activity line extension tool ``tool_name`` registered, if any."""
load()
tool = _tools.get(tool_name)
return tool.activity_label if tool is not None else None


def workflow_tools() -> list[ExtraTool]:
"""The extension tools workflow action steps may use (``workflow=True``)."""
load()
Expand Down Expand Up @@ -376,6 +410,7 @@ def _reset_for_tests() -> None:
"Collection",
"ExtensionError",
"ExtraTool",
"activity_label",
"chip_fields",
"collection_for_tags",
"collection_tag",
Expand All @@ -385,6 +420,7 @@ def _reset_for_tests() -> None:
"load",
"notify",
"offered_tools",
"private_row",
"register_collection",
"register_tool",
"workflow_step",
Expand Down
52 changes: 52 additions & 0 deletions packages/core/tests/unit/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,47 @@ def test_the_tool_decides_which_turns_it_is_offered_on() -> None:
assert "make_widget" not in offered[0]


def test_a_tool_asked_without_a_known_caller_needs_its_own_offered() -> None:
with pytest.raises(ExtensionError, match="offered"):
extensions.register_tool(_tool(known_caller=False))


def test_only_a_tool_registered_for_it_is_asked_on_a_turn_with_no_known_caller() -> None:
# A surface that names its user without proving who is speaking: the
# tool's own offered decides who may have it there.
extensions.register_tool(_tool("open_tool", known_caller=False, offered=lambda s: s.origin_channel == "kiosk"))
extensions.register_tool(_tool("plain_tool", offered=lambda _s: True))
provider, _, offered = _run(
_session(caller_person_id=None, origin_channel="kiosk"), [[_use("tu-1", "open_tool", {})]]
)
assert "open_tool" in offered[0] and "plain_tool" not in offered[0]
assert json.loads(_results(provider, 1)["tu-1"])["status"] == "made"
_, _, offered = _run(_session(caller_person_id=None), [])
assert "open_tool" not in offered[0]
# Still never on an unattended, private, AI-app or replay turn.
for kwargs in (
{"unattended": True},
{"private_to_principal": True},
{"from_mcp_client": True},
{"from_mcp_client": True, "mcp_client_may_act": True},
{"replay": True},
):
_, _, offered = _run(_session(caller_person_id=None, origin_channel="kiosk", **kwargs), [])
Comment thread
johnrufusone marked this conversation as resolved.
assert "open_tool" not in offered[0]


def test_a_tool_can_keep_its_dispatch_row_private_and_name_its_activity() -> None:
from openexecutive.orchestrator.activity_labels import summarize_activity
from openexecutive.orchestrator.executive import _private_tool_row

extensions.register_tool(_tool(private_row=True, activity_label="Making a widget…"))
extensions.register_tool(_tool("open_widget"))
assert _private_tool_row("make_widget") and not _private_tool_row("open_widget")
assert extensions.activity_label("make_widget") == "Making a widget…"
assert extensions.activity_label("open_widget") is None
assert "Making a widget" in str(summarize_activity([{"id": "tu-1", "name": "make_widget", "input": {}}]))


def test_a_failing_offered_counts_as_not_offered() -> None:
def _boom(_s: Any) -> bool:
raise RuntimeError("boom")
Expand Down Expand Up @@ -378,6 +419,17 @@ def test_a_chip_link_stays_inside_the_app(link: Any, kept: str | None) -> None:
assert out is not None and out["link"] == kept and out["summary"] == "Made" and out["target"] == "W"


def test_a_chip_describes_what_its_tool_waits_on() -> None:
# The tool's own approval, waiting for a tap: its chip describes it, never
# core's generic "Waiting for approval" for a held action.
chip = lambda _i, _p: {"summary": "Waiting for your approval: a widget", "link": "/today"} # noqa: E731
out = _chip({"status": "waiting_for_approval"}, chip)
assert out is not None and out["summary"] == "Waiting for your approval: a widget" and out["link"] == "/today"
# A chip that declines a waiting result gives none.
extensions._reset_for_tests()
assert _chip({"status": "waiting_for_approval"}, lambda _i, _p: None) is None


def test_a_chip_that_raises_or_has_no_summary_gives_none() -> None:
def _boom(_i: Any, _p: Any) -> dict[str, Any]:
raise RuntimeError("boom")
Expand Down
Loading