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
8 changes: 6 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,8 +372,12 @@ lines and the entry itself, and any other answer keeps the task on the entry. A
targets (readiness kind `route`): a missing, unready or switched-off target disables the row
with the reason on it. A route is admitted the way everything else at a boundary is
(**Admission**): it declares what its target's pipeline spends (`needs`, from the closed
`ROUTE_REQUIREMENTS` vocabulary) and the lowest tier it may open at (`minTier`), and the
entry checks only what was declared -- a route declaring neither is dispatched as routes
`ROUTE_REQUIREMENTS` vocabulary), a file the dispatch must hand over for the target to have
anything to build on (`needsFile`, a suffix; read off the files the user attached -- a direct
chat's media, or an attachment of the turn that the spawn task names -- and never off the task
text alone, since every deck brief spells the deck's destination like a template), and the
lowest tier it may open at (`minTier`),
and the entry checks only what was declared -- a route declaring none is dispatched as routes
were before the gate, since an empty declaration is verbatim pass-through. The readiness
probe answers for the *routed target's* lane, reading that product folder's own settings
first and falling back to what its launcher would inherit from the host; the host's own
Expand Down
11 changes: 7 additions & 4 deletions agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,14 @@ What one agent directory carries:
`"noteFile": "<name>.md"` beside the manifest instead -- discovery reads that
file into `note`, so a requirement long enough to be worth writing stays
reviewable as a diff; declaring both is refused. What puts a route under that
gate at all is its own declaration, and the two halves are independent:
gate at all is its own declaration, and the three parts are independent:
`"needs": ["image_generation", "image_search"]` names what the target's own
pipeline cannot work without, and `"minTier": "max"` the lowest tier the
route may open at. A route naming neither is dispatched exactly as routes
were before the gate existed -- never probed, never tiered -- because
pipeline cannot work without, `"needsFile": ".pptx"` a file the user attached
that the dispatch hands over (a direct chat's media, or an attachment of the
turn that the spawn task names -- a path in the text alone is not one) for
the route to open, and `"minTier": "max"` the lowest tier the route may open
at. A route naming none is dispatched exactly as routes were before the
gate existed -- never probed, never asked for a file, never tiered -- because
`routes` is a general facility and a row routing for reasons of its own must
not inherit conditions it never asked for. The names in `needs` come from a
closed vocabulary (`ROUTE_REQUIREMENTS`), since the host is what answers
Expand Down
1 change: 1 addition & 0 deletions agents/raven-design/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"contextWindowTokens": 262144,
"maxToolIterations": 400,
"llmCallTimeout": 1800,
"llmRetryAfterOutput": true,
"compaction": {
"enabled": true,
"triggerRatio": 0.8,
Expand Down
3 changes: 2 additions & 1 deletion agents/raven-design/modes/max.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"agents": {
"defaults": {
"maxToolIterations": 400
"maxToolIterations": 400,
"reasoningEffort": "xhigh"
}
}
}
19 changes: 11 additions & 8 deletions agents/raven-design/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
),
"max": (
"Max",
"400 tool iterations at the host's full reasoning effort. A full deliverable where the ceiling matters more than the bill.",
"400 tool iterations at xhigh reasoning effort. A full deliverable where the ceiling matters more than the bill.",
),
}
BASELINE_MODE = "high"
Expand Down Expand Up @@ -186,13 +186,16 @@ def render_config(source: Path) -> Path:
# the engine does not read either. The overlay stays on the entry as the
# record of what the mode changed.
#
# A mode that names an effort of its own keeps it; the rest inherit the
# host's. Medium is the one that names one: below the top tier this agent
# designs the deck itself instead of handing it to the template lane, and
# the cheapest tier asking for the host's full thinking budget on top of
# that is the combination nobody chose. The other two say nothing, which
# is the same as inheriting -- an unset entry effort reads
# ``agents.defaults.reasoningEffort``, and that is the host's.
# A mode that names an effort of its own keeps it; the baseline inherits
# the host's. This agent designs every deck itself, so what a tier buys is
# how hard it thinks about it: medium asks for low, max for xhigh (the
# rung below the ladder's top -- on the providers this lane runs against,
# ``max`` spends the whole output window on thinking), and high says
# nothing, which is the same as
# inheriting -- an unset entry effort reads ``agents.defaults.reasoningEffort``,
# and that is the host's. The overlay itself does not carry the effort --
# it is lifted onto the entry so the trunk dispenses it, and a copy in the
# diff the engine reads would be a second place to change it.
catalogue = render.mode_catalogue(
MODES_DIR,
MODE_LABELS,
Expand Down
5 changes: 5 additions & 0 deletions agents/raven-design/skills/deck-to-pptx/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ without: at most one call, before any other work, recommending the default. Wher
no user to ask (the request came from another agent), take the defaults. Say in the reply
which of the four were defaults.

Use what the user handed over as much as you can, before anything the rules below go and
get: their documents and data are the material, their pictures are the pictures. A `.pptx`
they attached is a template, and a deck on a template is the engine's work, not this
skill's: the host routes it there before this skill is read.

## Rules

1. Invent no number, no source, no person, no place. Search first. What the search does not
Expand Down
2 changes: 1 addition & 1 deletion agents/raven-design/subagent.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"image_generation",
"image_search"
],
"minTier": "max"
"needsFile": ".pptx"
}
],
"command": "{PYTHON} {SUBAGENT_DIR}/run.py --acp",
Expand Down
662 changes: 0 additions & 662 deletions plugins-dist/ppt-engine/docs/ppt-raven-design.md

This file was deleted.

4 changes: 2 additions & 2 deletions plugins-dist/ppt-engine/raven_ppt/profiles/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@

# Fail-closed kind. A claim about the source material that the file itself
# settles: a page citing one figure while showing another. Everything else warns
# -- see docs/ppt-raven-design.md D2 for why measurements of the rendered page
# must not refuse publication.
# -- a measurement of the rendered page is the renderer's word, not the file's,
# and must not refuse publication.
_PROVENANCE = frozenset({"citation"})

# What the deck was agreed to be. Refusals rather than warnings on every route: a
Expand Down
6 changes: 5 additions & 1 deletion raven/agent/loop/turn_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -2085,6 +2085,7 @@ async def _tap_episode(_index: int) -> None:
if on_episode_start is not None:
await on_episode_start(index)

from raven.agent.subagent.attachments import turn_attachments
from raven.agent.subagent.mode_tiers import turn_tier

async def _attempt(seed: list[dict], attempt: int):
Expand Down Expand Up @@ -2175,7 +2176,10 @@ def _rerun_pending(final_content: str | None, msgs: list[dict], status: str) ->
# The tier this turn dispatches sub-agents at, frozen here for the
# same reason the iteration cap is read once: a switch arriving mid-turn
# lands on the next turn, not on a sub-agent this one has yet to call.
with turn_tier(self.session_tier(key)):
# And its attachments, for the same reader: a dispatch that names a
# file the user attached is handing it over, one that names any other
# .pptx is not, and only the turn knows which is which.
with turn_tier(self.session_tier(key)), turn_attachments(req.media):
final_content, _, all_msgs, outcome = await _attempt(initial_messages, attempt_no)
# The conditional rerun. A dead turn has no answer to damage -- "empty
# implies wrong" is a scoring rule, so the count of right answers among
Expand Down
32 changes: 31 additions & 1 deletion raven/agent/subagent/attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,43 @@

from __future__ import annotations

from collections.abc import Iterable
from collections.abc import Iterable, Iterator, Sequence
from contextlib import contextmanager
from contextvars import ContextVar
from pathlib import Path
from typing import Any

from raven.spine.message import Media

ATTACHMENTS_NOTE = "[attachments, by absolute path]"

_TURN_MEDIA: ContextVar[tuple[Media, ...] | None] = ContextVar("raven_turn_attachments", default=None)


@contextmanager
def turn_attachments(media: Sequence[Media] | None) -> Iterator[None]:
"""Hold the turn's attachments where a dispatch inside it can read them.

A spawn and a DAG node receive no ``media``: the dispatching model writes
the paths it wants the lane to have into the task text, from the note the
host put on the user's message. What the turn actually carried is a fact
the host knows and the text does not settle -- a task names the deck's
destination in the same spelling as a template -- so it travels beside
the tier, as a ContextVar over the turn's own call graph, for the routing
entry to read (``RoutingBackend``).
"""
token = _TURN_MEDIA.set(tuple(media or ()))
try:
yield
finally:
_TURN_MEDIA.reset(token)


def turn_attachments_in_force() -> tuple[Media, ...]:
"""The attachments of the turn this dispatch runs inside; empty outside any turn."""
return _TURN_MEDIA.get() or ()


UNDELIVERABLE_NOTE = "[attachments were sent but not handed over: this agent cannot read local files]"


Expand Down
112 changes: 90 additions & 22 deletions raven/agent/subagent/backends/routing.py
Comment thread
0xKT marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@
and it is also the only thing on this seat that knows *what kind of work it
is*: the classifier answering a target is the entry learning that this task is
the target's specialty. ``_target_open`` then decides whether that specialty's
own lane may run here -- the tier this dispatch runs at, and whether the
deployment holds the credentials the target's pipeline spends. When it may not, the task stays on the
own lane may run here -- whether the deployment holds the credentials the
target's pipeline spends, whether the dispatch hands over the file the target
builds on, and the tier this dispatch runs at. When it may not, the task stays on the
row's own implementation and carries the closed route's own note with it, so the
lane that ends up building it is told what the other lane would have produced,
and how to produce it here.
Expand All @@ -47,12 +48,14 @@

from __future__ import annotations

from collections.abc import Awaitable, Callable, Sequence
import re
from collections.abc import Awaitable, Callable, Iterable, Sequence
from pathlib import Path
from typing import Any, NamedTuple

from loguru import logger

from raven.agent.subagent.attachments import turn_attachments_in_force
from raven.agent.subagent.backends.base import optional_keyword
from raven.agent.subagent.mode_tiers import turn_tier_in_force
from raven.config.schema import TIER_LADDER
Expand All @@ -79,15 +82,59 @@
HOST_PREFIX = "\n\n[host] "


def handed_files(*texts: str | None, media: Iterable[Any] = ()) -> tuple[str, ...]:
"""The files this dispatch hands the implementation, by path.

Only files the user attached count, and they are read from where the host
holds them rather than from the task text. A direct chat carries them as
``media`` (``Media`` rows, or plain paths): every one of those is handed
over, since the transport puts them beside the text. A spawn or a DAG node
carries none -- the dispatching model passes a file on by writing its path
into the task -- so there the turn's own attachments are read
(:func:`turn_attachments_in_force`) and one counts when the task names it.

Read this way rather than by scanning the text for anything that looks like
a file, because the text does not settle the question: every deck brief the
dispatching model writes names the deck's destination in the same spelling
as a template (``.../tmp/tui/history.pptx``), and one that mentions a stray
file in the working directory names that too. Neither was handed over.
Conversely an attachment the user told the model to ignore is not named in
the task, and stays where it is.
"""
given = [path for path in (_path_of(item) for item in media or ()) if path]
if given:
return tuple(given)
handed: list[str] = []
for item in turn_attachments_in_force():
path = _path_of(item)
if not path:
continue
# The path or its name, bounded the way a file name is on both sides:
# ``brand.pptx`` in ``brand.pptx.bak`` or in ``new_brand.pptx`` is a
# different file, while ``brand.pptx.`` at the end of a sentence is
# this one -- a dot closes the name unless it opens a longer one.
named = re.compile(r"(?<![\w.-])" + re.escape(Path(path).name) + r"(?![\w-])(?!\.[\w-])")
if any(text and named.search(text) for text in texts):
handed.append(path)
return tuple(handed)


def _path_of(item: Any) -> str:
"""A ``Media`` row's path, or a plain string path; anything else is not a file."""
path = getattr(item, "path", item)
return path if isinstance(path, str) else ""


class RouteTarget(NamedTuple):
"""One candidate target as the entry holds it.

``name`` and ``description`` are the roster line the classifier picks from;
``owes`` and ``note`` are the declaring row's own words for the case where
the gate keeps the work here, and ``needs`` and ``min_tier`` are what put a
route under the gate at all. A plain ``(name, description, backend)`` still
builds one, with every declared field saying nothing -- which is what a route
that declares nothing means, and such a route is neither probed nor tiered.
the gate keeps the work here, and ``needs``, ``min_tier`` and ``needs_file``
are what put a route under the gate at all. A plain ``(name, description,
backend)`` still builds one, with every declared field saying nothing --
which is what a route that declares nothing means, and such a route is
neither probed nor tiered nor asked for a file.
"""

name: str
Expand All @@ -97,6 +144,7 @@ class RouteTarget(NamedTuple):
note: str = ""
needs: tuple[str, ...] = ()
min_tier: str = ""
needs_file: str = ""

def hand_off(self) -> str:
"""What to append to a task classified for this route that stays here."""
Expand Down Expand Up @@ -180,21 +228,23 @@ def call(*args: Any, **kwargs: Any) -> None:

return call

def _target_open(self, target: RouteTarget, mode: str | None) -> str:
def _target_open(self, target: RouteTarget, mode: str | None, handed: Sequence[str] = ()) -> str:
"""``""`` when ``target`` may run this dispatch, else why it may not.

Two conditions, both about the deployment rather than the task, which is
why neither can be asked of the classifier -- and both read off the
*route's own declaration*, so a route that declares neither is returned
unconditionally open. ``routes`` is a general facility: a row routing for
reasons of its own would otherwise be closed by a credential its target
never spends and a tier its target never asked for, having opted into
nothing.
Three conditions, none of them a judgement the classifier could make:
two are about the deployment and the third is a fact about what the
dispatch carries. All three read off the *route's own declaration*, so a
route that declares none is returned unconditionally open. ``routes`` is
a general facility: a row routing for reasons of its own would otherwise
be closed by a credential its target never spends, a file it never asked
for and a tier it never named, having opted into nothing.

The credentials come first because they are the flat answer: a target
whose pipeline cannot buy a picture builds the same deck at every tier.
The tier comes second, and where it is read from depends on what kind of
dispatch this is; see :meth:`_tier_in_force`.
The file comes second: a target that builds on one the user supplies
has nothing to build on without it, at any tier. The tier comes last,
and where it is read from depends on what kind of dispatch this is; see
:meth:`_tier_in_force`.
"""
if target.needs and self._target_ready is not None:
try:
Expand All @@ -206,6 +256,10 @@ def _target_open(self, target: RouteTarget, mode: str | None) -> str:
ready = True
if not ready:
return "a credential its lane spends is not configured here"
if target.needs_file:
suffix = target.needs_file.lower()
if not any(name.lower().endswith(suffix) for name in handed):
return f"no {target.needs_file} the user attached was handed over"
if not target.min_tier:
return ""
where, tier = self._tier_in_force(mode)
Expand Down Expand Up @@ -248,9 +302,19 @@ def _tier_in_force(mode: str | None) -> tuple[str, str]:
return "the turn", turn.strip()

async def pick(
self, task: str, *, session_key: str | None, instance: str | None, mode: str | None = None
self,
task: str,
*,
session_key: str | None,
instance: str | None,
mode: str | None = None,
handed: Sequence[str] = (),
) -> Picked:
"""What runs this task; see the module docstring for the order."""
"""What runs this task; see the module docstring for the order.

``handed`` is what the dispatch carries by file name (see
:func:`handed_files`); a route declaring ``needs_file`` opens on it.
"""
if instance and (bound := await self._bound(session_key, instance)) is not None:
return bound
if self._router is not None and self._targets:
Expand All @@ -262,7 +326,7 @@ async def pick(
answer = None
for target in self._targets:
if answer == target.name:
if closed := self._target_open(target, mode):
if closed := self._target_open(target, mode, handed):
logger.info(
"{!r}: classified for {!r}, but {}; running here{}",
self.name,
Expand Down Expand Up @@ -317,7 +381,11 @@ async def run(
# from the same dict, and lifting it out would hand one to a backend the
# caller never passed one to.
picked = await self.pick(
authored_task or task, session_key=session_key, instance=instance, mode=kwargs.get("mode")
authored_task or task,
session_key=session_key,
instance=instance,
mode=kwargs.get("mode"),
handed=handed_files(task, authored_task, media=kwargs.get("media") or ()),
)
if picked.note:
# Both texts, because which one an implementation reads is its own
Expand All @@ -340,4 +408,4 @@ async def run(
)


__all__ = ["HOST_PREFIX", "Picked", "RouteTarget", "Router", "RoutingBackend", "TargetReady"]
__all__ = ["HOST_PREFIX", "Picked", "RouteTarget", "Router", "RoutingBackend", "TargetReady", "handed_files"]
Loading
Loading