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:
@@ -237,6 +237,48 @@ def _no_connection_or_path(name: str, agent: AgentContext | None, path: str) ->
|
||||
return _refused(name, agent, path, "no path was given.")
|
||||
|
||||
|
||||
# --- Proposing a plan -----------------------------------------------------------
|
||||
MAX_STEPS = 20
|
||||
|
||||
|
||||
async def _run_plan(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
"""Record a plan and stop.
|
||||
|
||||
Writes nothing and runs nothing, which is why it is `RISK_READ` and works in
|
||||
Plan mode without asking. The loop notices the event and ends the reply
|
||||
there: a plan followed by three more rounds of the model changing its mind
|
||||
is not a plan.
|
||||
"""
|
||||
agent = _agent(context)
|
||||
title = str(args.get("title") or "").strip() or "A plan"
|
||||
steps = [str(s).strip() for s in (args.get("steps") or []) if str(s).strip()]
|
||||
steps = steps[:MAX_STEPS]
|
||||
|
||||
if not steps:
|
||||
return ToolOutcome(
|
||||
"A plan needs at least one step. Say what you would actually do.",
|
||||
{"name": "plan_submit", "kind": "plan", "status": "error",
|
||||
"error": "No steps.", "results": []},
|
||||
)
|
||||
|
||||
return ToolOutcome(
|
||||
"Plan recorded. Stop here — they will read it and decide whether to "
|
||||
"carry it out. Do not start doing it.",
|
||||
{
|
||||
"name": "plan_submit",
|
||||
"kind": "plan",
|
||||
"label": agent.label if agent else "",
|
||||
"query": title,
|
||||
"status": "ok",
|
||||
"results": [],
|
||||
# Read back by the loop, which puts it on the message so the
|
||||
# Execute button sends exactly what was proposed rather than an
|
||||
# approximation parsed out of the prose.
|
||||
"plan": {"title": title, "steps": steps},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --- The definitions -----------------------------------------------------------
|
||||
def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
|
||||
"""The agent tools, bound to one chat's machine.
|
||||
@@ -245,8 +287,12 @@ def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
|
||||
needs: it maps an offered tool *name* back to its family and has no chat to
|
||||
resolve. Their runners still work -- they report that the conversation is
|
||||
not connected to a machine, which is true.
|
||||
|
||||
`plan_submit` is offered in Plan mode and nowhere else. It ends the reply,
|
||||
and a model in Auto mode that proposed a plan instead of doing the work
|
||||
would be obeying the wrong instinct at exactly the wrong moment.
|
||||
"""
|
||||
return [
|
||||
defs = [
|
||||
ToolDef(
|
||||
name="shell_run",
|
||||
family=FAMILY_AGENT,
|
||||
@@ -325,7 +371,37 @@ def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
|
||||
run=_run_list,
|
||||
risk=RISK_READ,
|
||||
),
|
||||
ToolDef(
|
||||
name="plan_submit",
|
||||
family=FAMILY_AGENT,
|
||||
description=(
|
||||
"Set out what you would do, as an ordered list of steps, and "
|
||||
"stop. Use this to finish when you have been asked to plan "
|
||||
"rather than to act: they will read it and decide whether to "
|
||||
"carry it out. Each step should be one thing, concrete enough "
|
||||
"to follow — name the files and the commands."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {**_STRING, "description": "What the plan achieves, in a line."},
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"items": _STRING,
|
||||
"description": "The steps, in order.",
|
||||
},
|
||||
},
|
||||
"required": ["title", "steps"],
|
||||
},
|
||||
run=_run_plan,
|
||||
# It writes nothing and runs nothing, so it needs no approval --
|
||||
# which is the point: Plan mode has to be able to finish.
|
||||
risk=RISK_READ,
|
||||
),
|
||||
]
|
||||
if context is not None and context.mode != policy.MODE_PLAN:
|
||||
return [tool for tool in defs if tool.name != "plan_submit"]
|
||||
return defs
|
||||
|
||||
|
||||
__all__ = ["FAMILY_AGENT", "MAX_EVENT_CHARS", "tool_defs"]
|
||||
|
||||
@@ -125,6 +125,10 @@ class Generation:
|
||||
# A model that fills its own context with build logs has no room left to
|
||||
# answer with.
|
||||
output_bytes: int = 0
|
||||
# A plan proposed in Plan mode: {"title": str, "steps": [str, ...]}. Ends
|
||||
# the reply and is written onto the message, so the Execute button sends
|
||||
# exactly what was proposed rather than something parsed back out of prose.
|
||||
plan: dict | None = None
|
||||
|
||||
def touch(self) -> None:
|
||||
self.version += 1
|
||||
@@ -440,10 +444,21 @@ async def _run(generation: Generation) -> None:
|
||||
generation.tool_events.append(outcome.event)
|
||||
generation.output_bytes += len(outcome.content)
|
||||
messages.append(tools_service.tool_turn(call, outcome.content))
|
||||
if outcome.event.get("plan"):
|
||||
generation.plan = outcome.event["plan"]
|
||||
generation.touch()
|
||||
|
||||
payload = {**payload, "messages": messages}
|
||||
|
||||
# A plan ends the turn. One more request so the model can say what
|
||||
# it proposed and why -- a bubble containing only a card reads as
|
||||
# though it 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 the reader is being asked to approve.
|
||||
if generation.plan is not None:
|
||||
offered = []
|
||||
payload.pop("tools", None)
|
||||
|
||||
for kind, piece in splitter.flush():
|
||||
(generation.reasoning if kind == REASONING else generation.content).append(piece)
|
||||
generation.touch()
|
||||
@@ -963,6 +978,7 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None:
|
||||
message.reasoning = generation.thinking
|
||||
message.reasoning_ms = generation.reasoning_ms
|
||||
message.tool_calls_json = generation.tool_events
|
||||
message.plan_json = generation.plan or {}
|
||||
message.usage_json = metrics_service.to_json(
|
||||
metrics_service.from_generation(generation)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user