Simplify tool approval chat flow

This commit is contained in:
mirivlad 2026-05-21 23:06:24 +08:00
parent 6f58df82c9
commit 10131cca48
4 changed files with 34 additions and 32 deletions

View File

@ -508,13 +508,6 @@ def create_app() -> FastAPI:
) )
messages = await runtime.context_builder.build_async_messages(task) messages = await runtime.context_builder.build_async_messages(task)
tool_observations = [tool_observation] tool_observations = [tool_observation]
if approval.decision != "deny" and not has_password_request(tool_observations):
tool_observations = await runtime._run_action_loop(
task_id,
messages,
task.workspace,
initial_observations=tool_observations,
)
async for tool_event in emit_tool_events(task_id, continued_event.sequence): async for tool_event in emit_tool_events(task_id, continued_event.sequence):
yield tool_event yield tool_event
if has_password_request(tool_observations): if has_password_request(tool_observations):
@ -696,13 +689,6 @@ def create_app() -> FastAPI:
) )
messages = await runtime.context_builder.build_async_messages(task) messages = await runtime.context_builder.build_async_messages(task)
tool_observations = [tool_observation] tool_observations = [tool_observation]
if not has_password_request(tool_observations):
tool_observations = await runtime._run_action_loop(
task_id,
messages,
task.workspace,
initial_observations=tool_observations,
)
async for tool_event in emit_tool_events(task_id, continued_event.sequence): async for tool_event in emit_tool_events(task_id, continued_event.sequence):
yield tool_event yield tool_event
if has_password_request(tool_observations): if has_password_request(tool_observations):

View File

@ -175,35 +175,39 @@ function formatToolCommand(tool, args) {
} }
function formatToolStart(tool, args) { function formatToolStart(tool, args) {
const lines = [formatToolCommand(tool, args)]; return formatToolCommand(tool, args);
const serializedArgs = JSON.stringify(args || {}, null, 2);
if (serializedArgs !== "{}") lines.push(serializedArgs);
return lines.join("\n");
} }
function appendToolTerminal(article, eventPayload) { function appendToolTerminal(article, eventPayload) {
const paragraph = article?.querySelector("p"); const paragraph = article?.querySelector("p");
const payload = eventPayload.payload || eventPayload;
const existing = findToolTerminal(article, payload.index);
if (existing) return existing;
const terminal = createToolTerminal(eventPayload); const terminal = createToolTerminal(eventPayload);
paragraph?.before(terminal); paragraph?.before(terminal);
document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight; document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight;
return terminal;
}
function findToolTerminal(article, index) {
return article?.querySelector(`.tool-terminal[data-tool-index="${index || ""}"]`);
} }
function updateToolTerminal(article, eventPayload) { function updateToolTerminal(article, eventPayload) {
const payload = eventPayload.payload || eventPayload; const payload = eventPayload.payload || eventPayload;
const terminal = article?.querySelector(`.tool-terminal[data-tool-index="${payload.index || ""}"]`); const terminal = findToolTerminal(article, payload.index);
const body = terminal?.querySelector(".tool-terminal-body"); const body = terminal?.querySelector(".tool-terminal-body");
const status = terminal?.querySelector(".tool-terminal-status"); const status = terminal?.querySelector(".tool-terminal-status");
const result = payload.result || {}; const result = payload.result || {};
if (!body || !status) return; if (!body || !status) return;
terminal.classList.toggle("is-error", !result.ok); terminal.classList.toggle("is-error", !result.ok);
terminal.classList.remove("is-waiting");
status.textContent = result.ok ? "ok" : "error"; status.textContent = result.ok ? "ok" : "error";
const parts = [body.textContent.trim()]; const title = terminal.querySelector(".tool-terminal-title")?.textContent || body.textContent.trim();
const parts = [title];
if (result.output) parts.push("\nstdout\n" + result.output.trimEnd()); if (result.output) parts.push("\nstdout\n" + result.output.trimEnd());
if (result.error) parts.push("\nstderr\n" + result.error.trimEnd()); if (result.error) parts.push("\nstderr\n" + result.error.trimEnd());
if (result.metadata && Object.keys(result.metadata).length) {
parts.push("\nmetadata\n" + JSON.stringify(result.metadata, null, 2));
}
body.textContent = parts.join("\n"); body.textContent = parts.join("\n");
document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight; document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight;
} }
@ -211,14 +215,13 @@ function updateToolTerminal(article, eventPayload) {
function appendApprovalTerminal(article, eventPayload) { function appendApprovalTerminal(article, eventPayload) {
const payload = eventPayload.payload || eventPayload; const payload = eventPayload.payload || eventPayload;
const action = payload.action || {}; const action = payload.action || {};
appendToolTerminal(article, { const terminal = appendToolTerminal(article, {
payload: { payload: {
index: payload.index, index: payload.index,
tool: payload.tool, tool: payload.tool,
args: action.args || {}, args: action.args || {},
}, },
}); });
const terminal = article?.querySelector(`.tool-terminal[data-tool-index="${payload.index || ""}"]`);
const body = terminal?.querySelector(".tool-terminal-body"); const body = terminal?.querySelector(".tool-terminal-body");
const status = terminal?.querySelector(".tool-terminal-status"); const status = terminal?.querySelector(".tool-terminal-status");
const command = formatToolCommand(action.tool || payload.tool, action.args || {}); const command = formatToolCommand(action.tool || payload.tool, action.args || {});
@ -228,15 +231,16 @@ function appendApprovalTerminal(article, eventPayload) {
if (status) status.textContent = "approval"; if (status) status.textContent = "approval";
if (body) { if (body) {
body.textContent = [ body.textContent = [
body.textContent,
"",
"approval required",
command, command,
"",
"Требуется разрешение на выполнение.",
payload.reason || "", payload.reason || "",
].filter(Boolean).join("\n"); ].filter(Boolean).join("\n");
} }
if (!terminal?.querySelector(".tool-approval-actions")) {
terminal?.append(createInlineApprovalActions(payload.approval_id, command)); terminal?.append(createInlineApprovalActions(payload.approval_id, command));
} }
}
function appendPasswordPrompt(article, eventPayload) { function appendPasswordPrompt(article, eventPayload) {
const payload = eventPayload.payload || eventPayload; const payload = eventPayload.payload || eventPayload;
@ -318,9 +322,13 @@ async function resolveInlineApproval(button) {
const decision = action === "deny" ? "denied" : "allowed"; const decision = action === "deny" ? "denied" : "allowed";
terminal.classList.toggle("is-error", action === "deny"); terminal.classList.toggle("is-error", action === "deny");
terminal.classList.remove("is-waiting"); terminal.classList.toggle("is-waiting", action !== "deny");
if (status) status.textContent = decision; if (status) status.textContent = action === "deny" ? decision : "running";
if (body) body.textContent = `${command}\n\n${decision}: ${humanApprovalDecision(action)}`; if (body) {
body.textContent = action === "deny"
? `${command}\n\n${decision}: ${humanApprovalDecision(action)}`
: `${command}\n\nРазрешено. Выполняю команду...`;
}
actions?.remove(); actions?.remove();
if (taskId) { if (taskId) {
await continueAfterInlineApproval(terminal.closest(".message"), taskId, approvalId); await continueAfterInlineApproval(terminal.closest(".message"), taskId, approvalId);

View File

@ -5,3 +5,7 @@ When asked who or what you are, answer as DuckLM. You may mention that DuckLM is
powered by a local model, but do not answer as the base model identity. powered by a local model, but do not answer as the base model identity.
Respond clearly and briefly unless the task needs detail. Respond clearly and briefly unless the task needs detail.
Do not reveal hidden reasoning, chain-of-thought, drafts, or step-by-step
internal analysis. Return only the user-facing answer. If tool observations are
present, summarize the useful result without echoing raw tool JSON or metadata
unless the user explicitly asks for raw output.

View File

@ -110,9 +110,12 @@ def test_stream_chat_endpoint_executes_tool_before_streaming_answer(tmp_path, mo
def test_continue_stream_executes_approved_tool_and_streams_answer(tmp_path, monkeypatch): def test_continue_stream_executes_approved_tool_and_streams_answer(tmp_path, monkeypatch):
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3")) monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
action_calls = 0
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None): async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
nonlocal action_calls
assert role == "action" assert role == "action"
action_calls += 1
if any("tool_observations" in message["content"] for message in messages): if any("tool_observations" in message["content"] for message in messages):
actions = [] actions = []
else: else:
@ -173,6 +176,7 @@ def test_continue_stream_executes_approved_tool_and_streams_answer(tmp_path, mon
assert "event: content_delta" in body assert "event: content_delta" in body
assert "continued after approval" in body assert "continued after approval" in body
assert "event: done" in body assert "event: done" in body
assert action_calls == 1
conversation_id = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', initial_body).group(1) conversation_id = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', initial_body).group(1)
conversation = client.get(f"/v1/conversations/{conversation_id}").json() conversation = client.get(f"/v1/conversations/{conversation_id}").json()
assert conversation["messages"][-1]["content"] == "continued after approval" assert conversation["messages"][-1]["content"] == "continued after approval"