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:
+80
-4
@@ -14,7 +14,15 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
|
||||
from lembas.db.models import (
|
||||
KIND_AGENT,
|
||||
KIND_CHAT,
|
||||
ROLE_ASSISTANT,
|
||||
ROLE_USER,
|
||||
Chat,
|
||||
Message,
|
||||
User,
|
||||
)
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.security import permissions
|
||||
from lembas.services import audio as audio_service
|
||||
@@ -24,8 +32,9 @@ from lembas.services import files as files_service
|
||||
from lembas.services import generation as generation_service
|
||||
from lembas.services import metrics as metrics_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import sse
|
||||
from lembas.services import settings_store, sse
|
||||
from lembas.services import tools as tools_service
|
||||
from lembas.services.agent import policy as agent_policy
|
||||
from lembas.services.markdown import escape_text, render_markdown
|
||||
from lembas.web.templating import render, templates
|
||||
|
||||
@@ -54,8 +63,16 @@ def _new_chat(
|
||||
folder_id: str = "",
|
||||
model_id: str = "",
|
||||
temporary: bool = False,
|
||||
kind: str = KIND_CHAT,
|
||||
ssh_profile_id: str = "",
|
||||
project_dir: str = "",
|
||||
) -> Chat:
|
||||
"""Create a chat row, resolving which model it should use."""
|
||||
"""Create a chat row, resolving which model it should use.
|
||||
|
||||
An agent chat's connection is settled here and never again. That is the
|
||||
lock: the harness, the tools offered and the approval loop all differ, so a
|
||||
conversation whose earlier turns ran somewhere else is not one conversation.
|
||||
"""
|
||||
chosen = None
|
||||
if model_id:
|
||||
match = next(
|
||||
@@ -66,12 +83,16 @@ def _new_chat(
|
||||
if chosen is None:
|
||||
chosen = chat_service.default_model(db, user)
|
||||
|
||||
profile = _agent_target(db, user, kind, ssh_profile_id)
|
||||
chat = Chat(
|
||||
user_id=user.id,
|
||||
folder_id=folder_id or None,
|
||||
model_id=chosen[0] if chosen else "",
|
||||
connection_id=chosen[1] if chosen else None,
|
||||
temporary=temporary,
|
||||
kind=KIND_AGENT if profile is not None else KIND_CHAT,
|
||||
ssh_profile_id=profile.id if profile is not None else None,
|
||||
project_dir=(project_dir.strip() or profile.default_dir) if profile is not None else "",
|
||||
)
|
||||
db.add(chat)
|
||||
db.commit()
|
||||
@@ -87,6 +108,9 @@ async def start_chat(
|
||||
folder_id: str = Form(""),
|
||||
model_id: str = Form(""),
|
||||
temporary: bool = Form(False),
|
||||
kind: str = Form(KIND_CHAT),
|
||||
ssh_profile_id: str = Form(""),
|
||||
project_dir: str = Form(""),
|
||||
) -> Response:
|
||||
"""Create a chat from its first message.
|
||||
|
||||
@@ -100,7 +124,14 @@ async def start_chat(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
chat = _new_chat(
|
||||
db, user, folder_id=folder_id, model_id=model_id, temporary=temporary
|
||||
db,
|
||||
user,
|
||||
folder_id=folder_id,
|
||||
model_id=model_id,
|
||||
temporary=temporary,
|
||||
kind=kind,
|
||||
ssh_profile_id=ssh_profile_id,
|
||||
project_dir=project_dir,
|
||||
)
|
||||
|
||||
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
|
||||
@@ -116,6 +147,32 @@ async def start_chat(
|
||||
return response
|
||||
|
||||
|
||||
def _agent_target(db: DBSession, user: User, kind: str, profile_id: str):
|
||||
"""The connection an agent chat is being pointed at, or None.
|
||||
|
||||
Every "no" collapses to None and the chat is an ordinary one: not asked
|
||||
for, no permission, the feature off, or a profile that is not this person's.
|
||||
Refusing outright would be worse -- somebody whose permission was withdrawn
|
||||
between opening the composer and sending would lose the message.
|
||||
"""
|
||||
from lembas.db.models import SshProfile
|
||||
from lembas.security import permissions
|
||||
|
||||
if kind != KIND_AGENT or not profile_id:
|
||||
return None
|
||||
if not permissions.has(db, user, "tools.agent"):
|
||||
return None
|
||||
if not settings_store.agents(db).get("enabled"):
|
||||
return None
|
||||
|
||||
profile = db.get(SshProfile, profile_id)
|
||||
# Ownership re-checked rather than trusted from the form: an id in a POST is
|
||||
# not an authorisation, and these are credentials to somebody's machine.
|
||||
if profile is None or profile.owner_id != user.id or not profile.enabled:
|
||||
return None
|
||||
return profile
|
||||
|
||||
|
||||
# There is deliberately no route that creates an empty chat. Starting one is
|
||||
# navigation to /chat (optionally ?model=...), and the row is written by
|
||||
# /start when the first message is actually sent.
|
||||
@@ -751,6 +808,25 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
||||
if "folder_id" in form:
|
||||
chat.folder_id = str(form["folder_id"]) or None
|
||||
|
||||
# The mode is the one agent field that changes mid-chat: it decides what
|
||||
# gets asked about, not what the conversation is.
|
||||
if "agent_mode" in form:
|
||||
wanted = str(form["agent_mode"]).strip()
|
||||
if wanted in agent_policy.MODES:
|
||||
chat.agent_mode = wanted
|
||||
|
||||
# And these are the ones that never do. Refused rather than ignored: a form
|
||||
# that quietly did nothing would look like a bug from the outside, and
|
||||
# without the refusal a crafted POST would repoint a conversation at another
|
||||
# machine halfway through.
|
||||
for locked in ("kind", "ssh_profile_id", "project_dir"):
|
||||
if locked in form:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"A chat's connection is fixed when it is created. Start a new "
|
||||
"chat to work somewhere else.",
|
||||
)
|
||||
|
||||
model_id = str(form.get("model_id", "")).strip()
|
||||
|
||||
if model_id:
|
||||
|
||||
Reference in New Issue
Block a user