Agent chats run commands, and stop to ask first

The four tools an agent chat has -- shell_run, file_read, file_write,
file_list -- and the mode table wired into the loop that decides which of
them stop for approval. Verified end to end against a real Kali container
over SSH: the card shows the command, allowing it runs it there, and the
file it writes is visible from outside.

The mode is enforced in `_authorise`, in the generation loop, server-side,
keyed on each tool's declared risk. Not in the prompt: a model is told
which mode it is in so it behaves sensibly, but everything it reads -- a
web page, a README, the output of the last command -- is untrusted, and a
rule written only into a system message is one a poisoned file can argue
with. Within an agent chat every call goes through the table, including
the built-in ones, because notes_edit writes and Plan mode meaning "look
but do not touch" has to mean that too.

Two things this turned up.

The runners re-check the mode as a backstop, and that backstop refused the
very thing a person had just approved -- the mode says "ask", and asking
was exactly what happened. Approval is now threaded per call, on a copy of
the context, because a round runs its calls together and only some of them
were allowed.

And the harness said nothing at all, because `registry` maps an offered
tool *name* back to a family and did not know the agent tools existed. So
shell_run resolved to no family and the fragment naming the machine, the
directory and the mode was never admitted. The same omission cost custom
tools their guidance once already; there is a test for it now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-02 00:08:48 +02:00
parent 6a849dc1ec
commit b6aab8de55
14 changed files with 1579 additions and 29 deletions
+33
View File
@@ -131,6 +131,10 @@ def context_variables(
"skills": skills_service.index_block(db, user) if "skills" in families else "",
"knowledge_bases": "",
"document_names": "",
"agent_target": "",
"agent_dir": "",
"agent_mode": "",
"agent_rewound": "",
}
if chat is not None:
@@ -145,9 +149,38 @@ def context_variables(
values["knowledge_bases"] = ", ".join(base.name for base in chat.knowledge_bases)
values["document_names"] = _document_names(db, chat)
# The one thing a tool description cannot carry, because a description
# is schema: which machine, which directory, and what this chat's mode
# currently permits. `max_rounds` is corrected here too, or an agent
# chat with forty rounds is told it has three.
if "agent" in families:
values.update(_agent_values(db, chat, user))
return values
def _agent_values(db: DBSession, chat, user) -> dict[str, str]:
"""What an agent chat's harness needs to say about where it is."""
from lembas.services.agent import policy
from lembas.services.agent import session as agent_session
context = agent_session.resolve(db, chat, user)
if context is None:
return {}
rewound = ""
if getattr(chat, "rewound_at", None) is not None:
rewound = chat.rewound_at.strftime("on %-d %B at %H:%M")
return {
"agent_target": context.label,
"agent_dir": context.project_dir or "the login directory",
"agent_mode": policy.MODE_GUIDANCE.get(context.mode, ""),
"agent_rewound": rewound,
"max_rounds": str(context.limits.steps),
}
def limit_for(db: DBSession) -> int:
"""The ceiling on the assembled block."""
stored = settings_store.get(db, "max_harness_chars", key=settings_store.PROMPTS)