Plan mode proposes, and you decide whether to carry it out
`plan_submit` records an ordered set of steps and ends the turn. Offered in Plan mode and nowhere else: it stops the reply, and a model in Auto mode proposing a plan instead of doing the work would be obeying the wrong instinct at the worst moment. The plan is stored on the message rather than parsed back out of the prose, so the button sends exactly what was proposed. It gets one more request to say what it proposed and why -- a bubble containing only a card reads as though the model had nothing to add -- but with the tools withdrawn, so "one more round" cannot become three rounds of it changing its mind about a plan somebody is being asked to approve. Carrying it out switches to Edit, never Auto. The plan was written under a mode where every command stopped for approval, and a button that also removed the asking is not the button anybody pressed. It goes back quoted and attributed, not stated: a plan whose text came out of a file the model read must not arrive in the most trusted role in the transcript wearing the reader's authority. Also closes the rewind gap. Editing or regenerating a turn rewinds the transcript and not the machine, so `rewound_at` is stamped and the harness says so. Nothing tries to undo anything out there -- the project directory is somebody's real working tree, and deleting their work to match a rewound transcript would be far worse than the inconsistency. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
||||
@@ -405,6 +406,37 @@ async def post_message(
|
||||
if not content and not file_ids:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
return _send(request, db, chat, user, content, file_ids=file_ids)
|
||||
|
||||
|
||||
def _note_rewind(chat: Chat) -> None:
|
||||
"""Record that an agent chat's transcript went back and the machine did not.
|
||||
|
||||
Deliberately no attempt to undo anything out there. The project directory is
|
||||
somebody's real working tree, and deleting their work to match a rewound
|
||||
transcript would be far worse than the inconsistency. So the model is told
|
||||
instead -- see the `tool.agent_rewound` fragment -- and can look rather than
|
||||
assume.
|
||||
"""
|
||||
if chat.kind == KIND_AGENT:
|
||||
chat.rewound_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def _send(
|
||||
request: Request,
|
||||
db: Db,
|
||||
chat: Chat,
|
||||
user: User,
|
||||
content: str,
|
||||
*,
|
||||
file_ids: list[str] | None = None,
|
||||
) -> Response:
|
||||
"""Write a turn, start the reply, and hand back the pair of bubbles.
|
||||
|
||||
Shared by the composer and by anything else that puts words into a
|
||||
conversation on somebody's behalf -- carrying out a plan, for one. One path
|
||||
rather than two, so a second way of sending cannot drift from the first.
|
||||
"""
|
||||
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
|
||||
if file_ids:
|
||||
files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id)
|
||||
@@ -701,6 +733,7 @@ async def edit_message(
|
||||
if cutoff is None or compaction_service.moment(message) <= compaction_service.moment(cutoff):
|
||||
compaction_service.reset(chat)
|
||||
|
||||
_note_rewind(chat)
|
||||
db.commit()
|
||||
|
||||
assistant = chat_service.create_message(
|
||||
@@ -714,6 +747,47 @@ async def edit_message(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{chat_id}/messages/{message_id}/execute-plan")
|
||||
async def execute_plan(
|
||||
request: Request, db: Db, user: RequiredUser, chat_id: str, message_id: str
|
||||
) -> Response:
|
||||
"""Carry out a plan the model proposed.
|
||||
|
||||
Switches to **Edit**, never Auto. The plan was written under a mode where
|
||||
every command stopped for approval, and a button that also removed the
|
||||
asking is not the button anybody pressed.
|
||||
|
||||
The plan is sent back **marked as a quotation of the model's own words**
|
||||
rather than as a bare instruction. A plan whose text came out of a file the
|
||||
model read would otherwise arrive in the most trusted role in the
|
||||
transcript, wearing the reader's authority -- which is precisely how an
|
||||
injected instruction would like to arrive.
|
||||
"""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
message = db.get(Message, message_id)
|
||||
if message is None or message.chat_id != chat.id or not message.plan_json:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "There is no plan on that message.")
|
||||
if chat.kind != KIND_AGENT:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "This chat cannot act on anything.")
|
||||
|
||||
plan = message.plan_json
|
||||
steps = [str(s) for s in (plan.get("steps") or [])]
|
||||
body = "\n".join(f"{n}. {step}" for n, step in enumerate(steps, start=1))
|
||||
|
||||
chat.agent_mode = agent_policy.MODE_EDIT
|
||||
db.commit()
|
||||
|
||||
content = (
|
||||
"Carry out the plan you proposed above:\n\n"
|
||||
f"> **{plan.get('title') or 'The plan'}**\n"
|
||||
+ "\n".join(f"> {line}" for line in body.splitlines())
|
||||
+ "\n\nWork through it in order. If a step turns out to be wrong, stop "
|
||||
"and say so rather than improvising around it."
|
||||
)
|
||||
log.info("%s executing a plan in chat %s", user.email, chat.id)
|
||||
return _send(request, db, chat, user, content)
|
||||
|
||||
|
||||
@router.post("/{chat_id}/messages/{message_id}/stop")
|
||||
async def stop_message(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> Response:
|
||||
"""Ask a running generation to stop.
|
||||
@@ -959,6 +1033,7 @@ async def regenerate(
|
||||
message.error = ""
|
||||
message.complete = False
|
||||
message.model_id = chat.model_id
|
||||
_note_rewind(chat)
|
||||
db.commit()
|
||||
# restart, not ensure: this is the one caller that reuses a Message row, and
|
||||
# the finished generation for it is still registered.
|
||||
|
||||
Reference in New Issue
Block a user