feat(ai): services partages A2 + SSE streaming B4 + confirmations UI B5 (#79)
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m15s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m15s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

This commit is contained in:
2026-09-11 17:06:40 -04:00
parent c55e3e0cbc
commit 4c4e415975
27 changed files with 1465 additions and 386 deletions
+67 -2
View File
@@ -98,6 +98,59 @@ def _assistant_tool_message(content: str | None, tool_calls: list[Any]) -> dict[
}
def _execute_confirmed(
ctx: ToolContext,
confirm_pending: dict[str, Any],
convo: list[dict[str, Any]],
executed: list[ToolCallRecord],
on_tool_call: Callable[[ToolCallRecord], None] | None,
) -> None:
"""Apply a previously-paused mutating tool call and feed its result back.
The pending payload is the ``error`` object emitted by a ``confirmation``
event. 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"
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)
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),
})
async def run_agent(
messages: list[dict[str, Any]],
*,
@@ -106,6 +159,8 @@ async def run_agent(
tools: list[dict[str, Any]] | None = None,
max_iterations: int = DEFAULT_MAX_ITERATIONS,
on_tool_call: Callable[[ToolCallRecord], None] | None = None,
resume_messages: list[dict[str, Any]] | None = None,
confirm_pending: dict[str, Any] | None = None,
) -> AgentResult:
"""Run the tool-calling loop until completion.
@@ -119,6 +174,10 @@ async def run_agent(
pass ``[]`` to disable tool calling (plain chat).
max_iterations: Hard cap on LLM round-trips.
on_tool_call: Optional callback invoked after each executed tool call.
resume_messages: Conversation snapshot from a paused run (returned with
a ``confirmation`` event). When set, the loop resumes from it.
confirm_pending: Pending mutating tool call to apply before resuming
(two-step propose/apply).
Returns:
An :class:`AgentResult`. ``stopped`` is ``done``, ``max_iterations`` or
@@ -129,9 +188,12 @@ async def run_agent(
if tools is None:
tools = get_tool_schemas(scope=ToolScope.IN_APP)
convo = [dict(m) for m in messages]
convo = [dict(m) for m in (resume_messages if resume_messages is not None else messages)]
executed: list[ToolCallRecord] = []
if confirm_pending:
_execute_confirmed(ctx, confirm_pending, convo, executed, on_tool_call)
for iteration in range(1, max_iterations + 1):
response = await llm(convo, tools)
@@ -153,13 +215,16 @@ async def run_agent(
ok = True
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
return AgentResult(
content=response.content or "",
messages=convo,
tool_calls=executed,
iterations=iteration,
stopped=STOP_CONFIRMATION_REQUIRED,
pending=e.to_dict(),
pending=pending,
)
except ToolError as e:
payload = e.to_dict()