feat: assistant IA — approbation groupee des actions, bloc d'etapes, refresh UI et bouton Stop (BUG-074, BUG-075, BUG-076, BUG-077)
This commit is contained in:
+121
-71
@@ -28,6 +28,7 @@ from backend.tools.api import (
|
||||
ToolError,
|
||||
ToolScope,
|
||||
call_tool,
|
||||
get_tool,
|
||||
get_tool_schemas,
|
||||
)
|
||||
from backend.tools.labels import thought_step_label, tool_step_label
|
||||
@@ -121,12 +122,15 @@ def _assistant_tool_message(content: str | None, tool_calls: list[Any]) -> dict[
|
||||
def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, Any]:
|
||||
"""Answer a tool call that was not reached because the run stopped early.
|
||||
|
||||
A single LLM response may carry several tool calls. When one of them is
|
||||
mutating and pauses the run for confirmation, the assistant message already
|
||||
lists *all* of them, so every ``tool_call_id`` must get a tool result before
|
||||
the next LLM call (the OpenAI tool protocol rejects dangling ids). The calls
|
||||
that were not reached get a synthetic ``deferred`` result; the model
|
||||
re-issues them once the confirmed call has been applied (BUG-050).
|
||||
A single LLM response may carry several tool calls; when the run stops
|
||||
before reaching some of them (tool-call quota), the assistant message still
|
||||
lists *all* of them, so every ``tool_call_id`` must get a tool result
|
||||
before the next LLM call (the OpenAI tool protocol rejects dangling ids).
|
||||
The calls that were not reached get a synthetic ``deferred`` result.
|
||||
|
||||
Note: mutating calls that pause the run for confirmation are no longer
|
||||
deferred — they are batched and applied together on resume (BUG-075); this
|
||||
helper remains for budget stops (BUG-050/BUG-052).
|
||||
"""
|
||||
return {
|
||||
"role": "tool",
|
||||
@@ -135,13 +139,29 @@ def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, An
|
||||
"content": json.dumps({
|
||||
"status": "deferred",
|
||||
"reason": reason or (
|
||||
"Not executed: the run paused to confirm an earlier tool call. "
|
||||
"Not executed: the run stopped before reaching this tool call. "
|
||||
"Re-issue this call if it is still needed."
|
||||
),
|
||||
}, ensure_ascii=False),
|
||||
}
|
||||
|
||||
|
||||
def _action_descriptor(call: Any) -> dict[str, Any]:
|
||||
"""Describe one paused mutating tool call for the confirmation payload.
|
||||
|
||||
A single LLM response may request several mutations (create a folder and
|
||||
the files inside it…). They are batched into one confirmation so the user
|
||||
approves the whole plan in one click (BUG-075). ``step`` reuses the
|
||||
Notion-style label, so the confirmation card reads like the steps block.
|
||||
"""
|
||||
return {
|
||||
"id": call.id,
|
||||
"tool": call.name,
|
||||
"arguments": call.arguments,
|
||||
"step": tool_step_label(call.name, call.arguments),
|
||||
}
|
||||
|
||||
|
||||
def _fallback_summary(executed: list[ToolCallRecord]) -> str:
|
||||
"""Deterministic non-empty answer built from the gathered tool results.
|
||||
|
||||
@@ -212,53 +232,66 @@ def _execute_confirmed(
|
||||
executed: list[ToolCallRecord],
|
||||
on_tool_call: Callable[[ToolCallRecord], None] | None,
|
||||
) -> None:
|
||||
"""Apply a previously-paused mutating tool call and feed its result back.
|
||||
"""Apply previously-paused mutating tool calls and feed their results back.
|
||||
|
||||
The pending payload is the ``error`` object emitted by a ``confirmation``
|
||||
event. The assistant tool-call message is expected to already be in
|
||||
event, optionally carrying an ``actions`` list with every mutating call of
|
||||
the LLM turn (BUG-075). Each action is applied with a one-shot confirmation
|
||||
and its ``tool_call_id`` answered, keeping the conversation valid for the
|
||||
resumed turn. The assistant tool-call message is expected to already be in
|
||||
``convo`` (it is part of the snapshot returned with the confirmation).
|
||||
"""
|
||||
from backend.ai_chat import ToolCall
|
||||
|
||||
error = confirm_pending.get("error", confirm_pending)
|
||||
name = error.get("tool")
|
||||
arguments = error.get("arguments") or {}
|
||||
call_id = error.get("id") or "call_pending"
|
||||
error = confirm_pending.get("error", confirm_pending) or {}
|
||||
actions = confirm_pending.get("actions")
|
||||
if not isinstance(actions, list) or not actions:
|
||||
# Legacy single-action payload (no ``actions`` list).
|
||||
actions = [{
|
||||
"id": error.get("id") or "call_pending",
|
||||
"tool": error.get("tool"),
|
||||
"arguments": error.get("arguments") or {},
|
||||
}]
|
||||
|
||||
if not name:
|
||||
raise ToolError("Malformed confirmation payload", code="invalid_confirmation")
|
||||
for action in actions:
|
||||
name = action.get("tool")
|
||||
arguments = action.get("arguments") or {}
|
||||
call_id = action.get("id") or "call_pending"
|
||||
|
||||
# Make sure the assistant tool-call message is present in the snapshot.
|
||||
if not any(
|
||||
m.get("role") == "assistant" and any(
|
||||
tc.get("id") == call_id for tc in (m.get("tool_calls") or [])
|
||||
if not name:
|
||||
raise ToolError("Malformed confirmation payload", code="invalid_confirmation")
|
||||
|
||||
# Make sure the assistant tool-call message is present in the snapshot.
|
||||
if not any(
|
||||
m.get("role") == "assistant" and any(
|
||||
tc.get("id") == call_id for tc in (m.get("tool_calls") or [])
|
||||
)
|
||||
for m in convo
|
||||
):
|
||||
convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)]))
|
||||
|
||||
try:
|
||||
result = call_tool(name, ctx, arguments, confirm=True)
|
||||
payload = result.data
|
||||
ok = True
|
||||
except ToolError as e:
|
||||
payload = e.to_dict()
|
||||
ok = False
|
||||
|
||||
record = ToolCallRecord(
|
||||
name=name, arguments=arguments, ok=ok, result=payload,
|
||||
step=tool_step_label(name, arguments),
|
||||
)
|
||||
for m in convo
|
||||
):
|
||||
convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)]))
|
||||
executed.append(record)
|
||||
if on_tool_call is not None:
|
||||
on_tool_call(record)
|
||||
|
||||
try:
|
||||
result = call_tool(name, ctx, arguments, confirm=True)
|
||||
payload = result.data
|
||||
ok = True
|
||||
except ToolError as e:
|
||||
payload = e.to_dict()
|
||||
ok = False
|
||||
|
||||
record = ToolCallRecord(
|
||||
name=name, arguments=arguments, ok=ok, result=payload,
|
||||
step=tool_step_label(name, arguments),
|
||||
)
|
||||
executed.append(record)
|
||||
if on_tool_call is not None:
|
||||
on_tool_call(record)
|
||||
|
||||
convo.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": name,
|
||||
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
|
||||
})
|
||||
convo.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": name,
|
||||
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
|
||||
})
|
||||
|
||||
|
||||
async def run_agent(
|
||||
@@ -315,6 +348,36 @@ async def run_agent(
|
||||
convo = [dict(m) for m in (resume_messages if resume_messages is not None else messages)]
|
||||
executed: list[ToolCallRecord] = []
|
||||
|
||||
def _run_call(call: Any) -> None:
|
||||
"""Execute one tool call, record it and answer its ``tool_call_id``.
|
||||
|
||||
``ToolConfirmationRequired`` propagates to the caller so the loop can
|
||||
pause and batch the mutating calls of the turn (BUG-075).
|
||||
"""
|
||||
try:
|
||||
result = call_tool(call.name, ctx, call.arguments)
|
||||
payload: Any = result.data
|
||||
ok = True
|
||||
except ToolConfirmationRequired:
|
||||
raise
|
||||
except ToolError as e:
|
||||
payload = e.to_dict()
|
||||
ok = False
|
||||
record = ToolCallRecord(
|
||||
name=call.name, arguments=call.arguments, ok=ok, result=payload,
|
||||
step=tool_step_label(call.name, call.arguments),
|
||||
)
|
||||
executed.append(record)
|
||||
steps.append(record.step)
|
||||
if on_tool_call is not None:
|
||||
on_tool_call(record)
|
||||
convo.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": call.id,
|
||||
"name": call.name,
|
||||
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
|
||||
})
|
||||
|
||||
if confirm_pending:
|
||||
if quota is not None and len(executed) >= quota:
|
||||
return AgentResult(
|
||||
@@ -357,19 +420,25 @@ async def run_agent(
|
||||
llm, convo, executed, steps, iteration, STOP_QUOTA_EXCEEDED
|
||||
)
|
||||
try:
|
||||
result = call_tool(call.name, ctx, call.arguments)
|
||||
payload = result.data
|
||||
ok = True
|
||||
_run_call(call)
|
||||
except ToolConfirmationRequired as e:
|
||||
logger.info(f"Agent paused: confirmation required for '{call.name}'")
|
||||
pending = e.to_dict()
|
||||
# Include the tool-call id so the client can echo it back.
|
||||
pending["error"]["id"] = call.id
|
||||
# BUG-050: the assistant message lists every tool call of this
|
||||
# batch, so answer the ones we did not reach to keep the
|
||||
# conversation valid for the resumed turn.
|
||||
for skipped in response.tool_calls[index + 1:]:
|
||||
convo.append(_deferred_tool_message(skipped))
|
||||
# BUG-075: batch every mutating call of this LLM turn so the
|
||||
# user approves the whole plan at once (one resume applies them
|
||||
# all) instead of approving one action after another. Read-only
|
||||
# calls of the batch run immediately and answer their
|
||||
# ``tool_call_id`` so the resumed turn stays valid.
|
||||
actions = [_action_descriptor(call)]
|
||||
for after in response.tool_calls[index + 1:]:
|
||||
spec = get_tool(after.name)
|
||||
if spec is not None and spec.requires_confirmation:
|
||||
actions.append(_action_descriptor(after))
|
||||
else:
|
||||
_run_call(after)
|
||||
pending["actions"] = actions
|
||||
return AgentResult(
|
||||
content=response.content or "",
|
||||
messages=convo,
|
||||
@@ -379,25 +448,6 @@ async def run_agent(
|
||||
stopped=STOP_CONFIRMATION_REQUIRED,
|
||||
pending=pending,
|
||||
)
|
||||
except ToolError as e:
|
||||
payload = e.to_dict()
|
||||
ok = False
|
||||
|
||||
record = ToolCallRecord(
|
||||
name=call.name, arguments=call.arguments, ok=ok, result=payload,
|
||||
step=tool_step_label(call.name, call.arguments),
|
||||
)
|
||||
executed.append(record)
|
||||
steps.append(record.step)
|
||||
if on_tool_call is not None:
|
||||
on_tool_call(record)
|
||||
|
||||
convo.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": call.id,
|
||||
"name": call.name,
|
||||
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
|
||||
})
|
||||
|
||||
logger.warning(f"Agent reached max iterations ({max_iterations})")
|
||||
return await _finalize_answer(
|
||||
|
||||
@@ -110,6 +110,12 @@ class BooksLMChatRequest(BaseModel):
|
||||
description="Conversation snapshot returned alongside a ``confirmation`` event, "
|
||||
"echoed back to resume the agent run.",
|
||||
)
|
||||
confirm_all: bool = Field(
|
||||
default=False,
|
||||
description="Global approval (BUG-075): apply every pending action of the batch "
|
||||
"and auto-approve the remaining mutating calls of the same run, "
|
||||
"so the run does not pause on each action.",
|
||||
)
|
||||
app_context: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Live client UI state for the General assistant: open_documents, "
|
||||
@@ -506,9 +512,11 @@ async def api_bookslm_agent(
|
||||
Same context as ``/chat`` but the model may call tools (read/search the
|
||||
vault) through the shared tool layer. Emits one ``tool`` event per executed
|
||||
tool call, then a final ``message`` event. Mutating tools pause the run with
|
||||
a ``confirmation`` event (two-step propose/apply) carrying the pending call
|
||||
and the conversation snapshot; the client resumes by echoing them back in
|
||||
``confirm`` / ``confirm_messages``.
|
||||
a ``confirmation`` event (two-step propose/apply) carrying the pending
|
||||
``actions`` (every mutating call of the turn) and the conversation snapshot;
|
||||
the client resumes by echoing them back in ``confirm`` / ``confirm_messages``,
|
||||
optionally with ``confirm_all`` to apply the whole batch and auto-approve the
|
||||
rest of the run (BUG-075).
|
||||
"""
|
||||
_validate_vision_support(req)
|
||||
system_prompt = _resolve_system_prompt(req, current_user, agent=True)
|
||||
@@ -523,6 +531,10 @@ async def api_bookslm_agent(
|
||||
messages.append({"role": "user", "content": _build_user_content(req, vault_path)})
|
||||
|
||||
ctx = ToolContext(user=current_user, mode=ToolMode.IN_APP)
|
||||
if req.confirm_all:
|
||||
# BUG-075: a single global approval authorizes the whole plan, so the
|
||||
# run no longer pauses on every subsequent mutating call.
|
||||
ctx.confirmed = True
|
||||
|
||||
async def _llm(msgs, tool_schemas):
|
||||
return await chat_completion(
|
||||
|
||||
Reference in New Issue
Block a user