diff --git a/duck_core/api.py b/duck_core/api.py index aaa1233..0279155 100644 --- a/duck_core/api.py +++ b/duck_core/api.py @@ -508,13 +508,6 @@ def create_app() -> FastAPI: ) messages = await runtime.context_builder.build_async_messages(task) 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): yield tool_event if has_password_request(tool_observations): @@ -696,13 +689,6 @@ def create_app() -> FastAPI: ) messages = await runtime.context_builder.build_async_messages(task) 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): yield tool_event if has_password_request(tool_observations): diff --git a/duck_core/web/static/app.js b/duck_core/web/static/app.js index 4d1817b..84a535b 100644 --- a/duck_core/web/static/app.js +++ b/duck_core/web/static/app.js @@ -175,35 +175,39 @@ function formatToolCommand(tool, args) { } function formatToolStart(tool, args) { - const lines = [formatToolCommand(tool, args)]; - const serializedArgs = JSON.stringify(args || {}, null, 2); - if (serializedArgs !== "{}") lines.push(serializedArgs); - return lines.join("\n"); + return formatToolCommand(tool, args); } function appendToolTerminal(article, eventPayload) { const paragraph = article?.querySelector("p"); + const payload = eventPayload.payload || eventPayload; + const existing = findToolTerminal(article, payload.index); + if (existing) return existing; const terminal = createToolTerminal(eventPayload); paragraph?.before(terminal); 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) { 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 status = terminal?.querySelector(".tool-terminal-status"); const result = payload.result || {}; if (!body || !status) return; terminal.classList.toggle("is-error", !result.ok); + terminal.classList.remove("is-waiting"); 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.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"); document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight; } @@ -211,14 +215,13 @@ function updateToolTerminal(article, eventPayload) { function appendApprovalTerminal(article, eventPayload) { const payload = eventPayload.payload || eventPayload; const action = payload.action || {}; - appendToolTerminal(article, { + const terminal = appendToolTerminal(article, { payload: { index: payload.index, tool: payload.tool, args: action.args || {}, }, }); - const terminal = article?.querySelector(`.tool-terminal[data-tool-index="${payload.index || ""}"]`); const body = terminal?.querySelector(".tool-terminal-body"); const status = terminal?.querySelector(".tool-terminal-status"); const command = formatToolCommand(action.tool || payload.tool, action.args || {}); @@ -228,14 +231,15 @@ function appendApprovalTerminal(article, eventPayload) { if (status) status.textContent = "approval"; if (body) { body.textContent = [ - body.textContent, - "", - "approval required", command, + "", + "Требуется разрешение на выполнение.", payload.reason || "", ].filter(Boolean).join("\n"); } - terminal?.append(createInlineApprovalActions(payload.approval_id, command)); + if (!terminal?.querySelector(".tool-approval-actions")) { + terminal?.append(createInlineApprovalActions(payload.approval_id, command)); + } } function appendPasswordPrompt(article, eventPayload) { @@ -318,9 +322,13 @@ async function resolveInlineApproval(button) { const decision = action === "deny" ? "denied" : "allowed"; terminal.classList.toggle("is-error", action === "deny"); - terminal.classList.remove("is-waiting"); - if (status) status.textContent = decision; - if (body) body.textContent = `${command}\n\n${decision}: ${humanApprovalDecision(action)}`; + terminal.classList.toggle("is-waiting", action !== "deny"); + if (status) status.textContent = action === "deny" ? decision : "running"; + if (body) { + body.textContent = action === "deny" + ? `${command}\n\n${decision}: ${humanApprovalDecision(action)}` + : `${command}\n\nРазрешено. Выполняю команду...`; + } actions?.remove(); if (taskId) { await continueAfterInlineApproval(terminal.closest(".message"), taskId, approvalId); diff --git a/prompts/roles/thinker.md b/prompts/roles/thinker.md index 5fd0dbf..91be173 100644 --- a/prompts/roles/thinker.md +++ b/prompts/roles/thinker.md @@ -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. 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. diff --git a/tests/smoke/test_api_stream_chat.py b/tests/smoke/test_api_stream_chat.py index 8b3e42e..56f5320 100644 --- a/tests/smoke/test_api_stream_chat.py +++ b/tests/smoke/test_api_stream_chat.py @@ -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): 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): + nonlocal action_calls assert role == "action" + action_calls += 1 if any("tool_observations" in message["content"] for message in messages): actions = [] 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 "continued after approval" 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 = client.get(f"/v1/conversations/{conversation_id}").json() assert conversation["messages"][-1]["content"] == "continued after approval"