Simplify tool approval chat flow
This commit is contained in:
parent
6f58df82c9
commit
10131cca48
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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,14 +231,15 @@ 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");
|
||||||
}
|
}
|
||||||
terminal?.append(createInlineApprovalActions(payload.approval_id, command));
|
if (!terminal?.querySelector(".tool-approval-actions")) {
|
||||||
|
terminal?.append(createInlineApprovalActions(payload.approval_id, command));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendPasswordPrompt(article, eventPayload) {
|
function appendPasswordPrompt(article, 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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue