Files
LLeMbas/src/lembas/services/agent/session.py
T
Jaroslav Beneš e16bede85b Work handed to a second model, which may not ask
subagent_run gives a self-contained piece of work to a helper carrying the
parent's connection, directory, model and effort, and hands its answer back as
the tool result. The mechanism is the one scheduled runs already use -- a hidden
chat, one turn, wake_chat, and a poll -- so tools, rounds, budgets, metrics and
steps all work with no second implementation. The two alternatives were
rejected where they had already been rejected once: a nested Generation is two
replies writing one transcript, and a one-shot complete() has no tools, which
schedule/runner.py records as useless for exactly this case.

Every restriction is a property of the child's row, applied by resolve_tools
after the gates, because a rule that lives in a system message is one a page the
model just read can argue with. No questions, no recursion, nothing that writes
unless the call asked for it and the parent's own mode would not have stopped
first, and commands only from a fixed read-only list -- in every mode including
Auto, because the task text can have come from a page.

Withdrawing ask_user turned out to be half of "nobody is watching". An approval
still built a card nobody could see and parked the reply until approval_timeout,
which from every screen is the feature not working. Chat.unattended is the
question now, and not the kind: _authorise answers with a refusal instead. A
scheduled task's chat had the same hole and is covered by the same flag.

Three bounds, counted where each is knowable: per reply on the parent's
Generation, instance-wide in a set a restart clears, and per helper in settings
of its own so one runs out of room long before the reply that asked. Past the
clock the helper is stopped rather than abandoned, so a partial answer comes
back with a sentence saying so.

Also: four gates had shipped into the scope menu with no name, taking the first
tool's label instead -- the canvas switch read "Canvas written". There is a test
that refuses a family without one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:05:30 +02:00

278 lines
12 KiB
Python

"""What one agent chat is pointed at, resolved while a session is open.
Everything a runner needs travels in `AgentContext`: the machine, the decrypted
credential, the mode in force, and the two lists that adjust it. Nothing is
looked up later, for the reason `Endpoint` is a frozen copy of a `Connection`
and `ToolContext` carries an owner id rather than a `User` -- a generation
outlives the request that started it, and a detached instance is a trap.
The mode is read **once, at the start of the reply**, and deliberately does not
change under a reply already in flight. Somebody switching to Auto halfway
through must not retroactively approve what is already queued.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field, replace
from typing import Any
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import KIND_AGENT, Chat, SshProfile, User
from lembas.services import settings_store
from lembas.services.agent import hosts, policy
from lembas.services.agent import ssh as ssh_service
from lembas.services.agent.base import Executor
from lembas.services.agent.policy import Limits
log = logging.getLogger(__name__)
@dataclass
class AgentContext:
"""The machine an agent chat acts on, and what it may do there."""
chat_id: str
label: str
project_dir: str
# The connection's id, carried so a runner can drop the project listing it
# has just invalidated. `index` is keyed on the connection and the
# directory, not on the chat -- two chats on one tree share a listing.
profile_id: str = ""
mode: str = policy.MODE_MANUAL
allow: tuple[str, ...] = ()
deny: tuple[str, ...] = ()
limits: Limits = field(default_factory=Limits)
# Per-command bounds, from the instance settings.
timeout: float = 60.0
max_timeout: float = 600.0
max_output: int = 64 * 1024
# The decrypted credential. Held here and nowhere else, and cleared by
# `generation` when the reply ends -- a finished Generation lingers five
# minutes so late followers get the final frames, and a private key should
# not linger with it.
spec: dict[str, Any] = field(default_factory=dict)
# Set only on the per-call copy handed to a runner whose call a person has
# just allowed. The runners re-check the mode as a backstop, and without
# this they would refuse the very thing that was approved -- the mode says
# "ask", and asking is exactly what happened.
approved: bool = False
# Absolute paths this reply has read. `file_edit` refuses a file that is not
# in here, because a patch written from memory against a file the model has
# not looked at is how a rewrite silently loses somebody's work.
#
# Here rather than on `Generation` for two reasons. Runners never see a
# Generation -- they get a `ToolContext`, which is a session-free snapshot
# precisely so nothing in a tool holds live state -- and a read path is a
# fact about the machine, which is what this class is.
#
# It is **shared with the approved copy**: `as_approved` is
# `dataclasses.replace`, which copies field references, so a path read
# through an approved call is visible here. That is wanted and is not
# obvious, so there is a test for it.
#
# It resets each reply, and that is correct rather than a limitation.
# `Message.tool_calls_json` is deliberately never replayed as context, so on
# the next turn the model does not have the file's contents either --
# requiring a re-read in the reply that edits is asking for something it
# needs anyway.
read_paths: set[str] = field(default_factory=set)
# The plan currently in force, seeded from `chat.plan_message_id` when this
# is resolved. Mutable and read/written in place by `plan_update`, for a
# reason that is not obvious: a runner cannot write the message row --
# `_persist` is the single writer -- so it returns the merged plan on its
# event and the loop carries it. Two updates in one reply would then both
# read the same stale plan from the database and the second would lose the
# first. This snapshot is what they actually merge into.
plan: dict[str, Any] = field(default_factory=dict)
# Whether commands may run detached. When off, `shell_run` is byte-for-byte
# what it always was and the `job_*` tools are not offered -- a command that
# times out is killed, as before. When on, a command can be launched in the
# background (or converted to one when it times out) and the model gets the
# tools to check on it. `on_timeout` is the sub-switch for the auto-convert.
background: bool = False
background_on_timeout: bool = True
# Whether a finished job wakes the model on its own, rather than only being
# seen when it next runs. Read by the wording here and by the watcher.
background_notify: bool = True
# Most jobs watched at once. A watcher is a periodic reconnect, so this is a
# real resource; past it a job still runs but is not watched or woken for.
background_max_jobs: int = 5
def executor(self) -> Executor:
return ssh_service.SshExecutor(self.spec, self.project_dir)
def clear(self) -> None:
self.spec = {}
def as_approved(self) -> AgentContext:
"""A copy of this context for one call a person has allowed."""
return replace(self, approved=True)
def _plan_of(db: DBSession, chat: Chat) -> dict[str, Any]:
"""The plan this chat is working to, or an empty dict.
One `db.get` by primary key -- the column exists to avoid a scan for "the
newest message carrying a plan", because this runs while a request is
waiting. The id is validated here rather than constrained in the schema, for
the reason the column's comment gives.
"""
from lembas.db.models import Message
from lembas.services import plans
if not chat.plan_message_id:
return {}
message = db.get(Message, chat.plan_message_id)
if message is None or message.chat_id != chat.id:
return {}
return plans.normalise(message.plan_json)
def profile_for(db: DBSession, chat: Chat, user: User | None) -> SshProfile | None:
"""The connection this chat is pointed at, if it is still usable.
Ownership is re-checked here rather than trusted from when the chat was
created: a profile can be deleted, disabled, or moved to a host whose key
has not been confirmed since, and any of those should stop the chat acting
rather than be discovered at the first command.
"""
if chat is None or chat.kind != KIND_AGENT or not chat.ssh_profile_id:
return None
profile = db.get(SshProfile, chat.ssh_profile_id)
if profile is None or not profile.enabled:
return None
if user is not None and profile.owner_id != user.id:
return None
# A row can predate a setting, so this is asked here rather than trusted
# from when the profile was saved: an administrator moving the switch to
# `off` has to stop the chats already pointed at loopback, not only the next
# one somebody tries to create. See services/agent/hosts.py.
if not hosts.usable(db, profile):
return None
return profile
def _allow_for(chat: Chat) -> tuple[str, ...]:
"""Imported inside `resolve` rather than at module scope.
`services/tools.py` imports this module's `resolve`, so a top-level import
back the other way is a cycle.
"""
from lembas.services import tools as tools_service
return tools_service.scoped_allow(chat)
def refresh(db: DBSession, agent: AgentContext) -> AgentContext:
"""Re-read the two things a person can change while a reply is running.
The mode and the chat's own allow list, and nothing else. Everything else on
the context is fixed for the life of a chat (the connection, the directory)
or is an instance setting nobody is editing mid-reply.
Called once per round rather than once per reply. The reply-long snapshot it
replaces made both controls do nothing until the next turn: switching to
Auto during a long agent reply went on asking about every call, and
"Always allow this" was accepted, written to the row, and then ignored for
the rest of the reply that had just asked. Both look exactly like a control
that does not work, because for that reply they were.
Once per *round* and not more often, because a round's calls are authorised
together: what is already queued was decided under the mode that was in
force when it was queued, and switching to Auto must not retroactively
approve it. Mutated in place -- `as_approved` copies field references, so a
replacement here would leave the approved copy of this round pointing at the
old one.
"""
chat = db.get(Chat, agent.chat_id)
if chat is None:
return agent
agent.mode = chat.agent_mode if chat.agent_mode in policy.MODES else policy.MODE_MANUAL
instance = settings_store.agents(db)
agent.allow = (*(instance.get("allow_default") or ()), *_allow_for(chat))
return agent
def _limits_for(db: DBSession, chat: Chat, values: dict[str, Any]) -> Limits:
"""What this chat's replies may spend.
A helper's chat is sized by its own settings rather than the instance's,
because a reply answering one delegated question is not the same shape of
work as the reply that asked it: it should run out of room long before its
parent does, and an agent chat's own numbers are deliberately generous
enough to run for a quarter of an hour. `output_bytes` is shared, being a
property of what a command can hand back rather than of who asked.
`or 0` is avoided on the completion ceiling in both branches: zero is how an
administrator says "no ceiling", and the accessors have already clamped it.
"""
if chat.parent_chat_id:
sub = settings_store.subagents(db)
return Limits(
steps=int(sub["max_rounds"]),
wall_seconds=float(sub["wall_seconds"]),
output_bytes=int(values.get("max_total_output_bytes") or 1024 * 1024),
completion_tokens=int(sub.get("max_completion_tokens", 60_000) or 0),
)
return Limits(
steps=int(values.get("max_steps") or 200),
wall_seconds=float(values.get("max_wall_seconds") or 900),
output_bytes=int(values.get("max_total_output_bytes") or 1024 * 1024),
completion_tokens=int(values.get("max_completion_tokens", 200_000) or 0),
)
def resolve(db: DBSession, chat: Chat, user: User | None) -> AgentContext | None:
"""This chat's agent setup, or None if it has none it can use.
None is the answer to every "no": not an agent chat, the feature switched
off, the connection gone or disabled, SSH not installed. Each of those means
the agent tools are not offered at all, which is better than offering a tool
that fails on its first call.
A profile whose host key has never been confirmed is deliberately *not* one
of them. The tools are offered and the failure is explicit, because "check
the connection and accept its fingerprint" is a thing the reader can act on,
while a silently missing tool is not.
"""
profile = profile_for(db, chat, user)
if profile is None:
return None
values = settings_store.agents(db)
if not values.get("enabled"):
return None
if ssh_service.available():
return None
return AgentContext(
chat_id=chat.id,
label=profile.label,
plan=_plan_of(db, chat),
project_dir=chat.project_dir or profile.default_dir or "",
profile_id=profile.id,
mode=chat.agent_mode if chat.agent_mode in policy.MODES else policy.MODE_MANUAL,
# The instance's list, plus whatever this chat's reader has said
# "always" to on a card. Never the other way round for the deny list:
# a chat cannot un-deny anything, and `decide` consults deny first
# regardless.
allow=(*(values.get("allow_default") or ()), *_allow_for(chat)),
deny=tuple(values.get("deny_default") or ()),
limits=_limits_for(db, chat, values),
timeout=float(values.get("default_timeout") or 60),
max_timeout=float(values.get("max_timeout") or 600),
max_output=int(values.get("max_output_bytes") or 64 * 1024),
background=bool(values.get("background_enabled")),
background_on_timeout=bool(values.get("background_on_timeout", True)),
background_notify=bool(values.get("background_notify", True)),
background_max_jobs=int(values.get("background_max_jobs") or 5),
spec=ssh_service.spec_from(profile),
)
__all__ = ["AgentContext", "profile_for", "resolve"]