fix(agent): make ask_user question cards actually appear
The loop raised the end-turn control flow BEFORE logging/emitting the ask_user tool result, so the frontend never received the question — the chat sat on "working…" with no card until the user hit Stop or refreshed (the report: "the agent loop just stops there"). The result is now logged + emitted first, then the turn ends with status awaiting_input. - Question card renders the question text plus stacked option buttons (long sentences read cleanly) and a hint line. - Card answers are graded by first word: "Yes, replace with ~40 shorter scenes" grants, "No, keep existing scenes" refuses — previously a sentence option had to match "yes" exactly. - Two regression tests: tool/result precedes turn/end with the question payload; option-sentence approval matrix.
This commit is contained in:
@@ -269,18 +269,10 @@ async def run_turn(
|
||||
and result.get("ok") is not False
|
||||
):
|
||||
repeat_cache[repeat_key] = result
|
||||
# ask_user ends the turn: the agent's question waits for the
|
||||
# user's answer, so the loop must not burn steps polling.
|
||||
if isinstance(result, dict) and result.get("awaiting_user_input"):
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc["id"],
|
||||
"content": json.dumps(result, ensure_ascii=False, default=str),
|
||||
}
|
||||
)
|
||||
turn_status = "awaiting_input"
|
||||
raise _AwaitingInput()
|
||||
# Log + emit the result FIRST. The ask_user card and the
|
||||
# tool-row spinner both read these; ending the turn before
|
||||
# them left the UI with no question and a stuck "working…"
|
||||
# (the "agent loop just stops there" bug).
|
||||
log_append(
|
||||
session_log.TOOL_RESULT,
|
||||
{
|
||||
@@ -322,6 +314,12 @@ async def run_turn(
|
||||
"content": "",
|
||||
}
|
||||
)
|
||||
# ask_user ends the turn: the agent's question waits for the
|
||||
# user's answer, so the loop must not burn steps polling.
|
||||
if isinstance(result, dict) and result.get("awaiting_user_input"):
|
||||
log_append(session_log.STEP_END, {"turn": turn_no, "step": iteration})
|
||||
turn_status = "awaiting_input"
|
||||
raise _AwaitingInput()
|
||||
log_append(session_log.STEP_END, {"turn": turn_no, "step": iteration})
|
||||
else:
|
||||
final_text = (
|
||||
|
||||
@@ -143,6 +143,31 @@ def user_allows_render(ctx: ToolContext) -> bool:
|
||||
# question/answered immediately before its user/message echo.
|
||||
|
||||
_AFFIRMATIVE_ANSWERS = frozenset({"yes", "y", "ok", "okay", "sure", "confirm", "go ahead", "do it"})
|
||||
# Card options are full sentences ("Yes, replace with ~40 shorter scenes").
|
||||
# The FIRST word decides: yes-family grants, no-family refuses, anything
|
||||
# else falls back to the exact-match set. Mechanical, not prose guessing.
|
||||
_AFFIRMATIVE_FIRST_WORDS = frozenset(
|
||||
{"yes", "yep", "yeah", "yup", "y", "ok", "okay", "sure", "confirm", "confirmed",
|
||||
"proceed", "go", "do", "please", "fine", "absolutely", "definitely"}
|
||||
)
|
||||
_NEGATIVE_FIRST_WORDS = frozenset(
|
||||
{"no", "nope", "not", "dont", "don't", "do not", "never", "cancel", "stop",
|
||||
"skip", "wait", "hold"}
|
||||
)
|
||||
|
||||
|
||||
def _answer_is_affirmative(text: str) -> bool:
|
||||
t = text.strip().lower()
|
||||
if not t:
|
||||
return False
|
||||
if t in _AFFIRMATIVE_ANSWERS:
|
||||
return True
|
||||
first = re.split(r"[,\s.!]+", t, maxsplit=1)[0]
|
||||
if first in _AFFIRMATIVE_FIRST_WORDS:
|
||||
return True
|
||||
if first in _NEGATIVE_FIRST_WORDS or t.startswith(("don't", "do not", "no ")):
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def latest_answer(session_id: int) -> dict[str, Any] | None:
|
||||
@@ -176,5 +201,4 @@ def has_structured_approval(ctx: ToolContext, scope: str) -> bool:
|
||||
answer = latest_answer(ctx.session_id)
|
||||
if not answer or answer.get("scope") != scope:
|
||||
return False
|
||||
text = str(answer.get("answer") or "").strip()
|
||||
return text.lower() in _AFFIRMATIVE_ANSWERS
|
||||
return _answer_is_affirmative(str(answer.get("answer") or ""))
|
||||
|
||||
@@ -171,3 +171,82 @@ def test_answer_to_message_records_structured_approval(client, session):
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert policy.has_structured_approval(ctx, "render") is True
|
||||
assert policy.user_allows_render(ctx) is True
|
||||
|
||||
|
||||
def test_card_option_sentence_grants_approval():
|
||||
"""Options are full sentences ("Yes, replace with ~40 shorter scenes");
|
||||
the first word decides. Exact 'yes' still grants; 'No, …' refuses."""
|
||||
from calliope.agent.harness.policy import _answer_is_affirmative
|
||||
|
||||
assert _answer_is_affirmative("Yes, replace with ~40 shorter scenes") is True
|
||||
assert _answer_is_affirmative("yes go!") is True
|
||||
assert _answer_is_affirmative("Sure, do it") is True
|
||||
assert _answer_is_affirmative("Go ahead") is True
|
||||
assert _answer_is_affirmative("No, keep existing scenes and add more at the end") is False
|
||||
assert _answer_is_affirmative("Just update durations, don't change content") is False
|
||||
assert _answer_is_affirmative("no") is False
|
||||
assert _answer_is_affirmative("") is False
|
||||
|
||||
|
||||
def test_ask_user_logs_tool_result_before_turn_end(client, session):
|
||||
"""The card derives from the persisted ask_user tool ROW, which needs the
|
||||
tool/result event — the loop must log + emit the result BEFORE ending the
|
||||
turn, or the UI shows a stuck 'working…' with no question (the reported
|
||||
bug: user had to Stop/refresh to see anything)."""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from calliope.agent.harness.loop import run_turn
|
||||
|
||||
class _AskStream:
|
||||
def __aiter__(self):
|
||||
async def gen():
|
||||
yield {
|
||||
"type": "tool_call",
|
||||
"tool_call": {
|
||||
"id": "c1",
|
||||
"function": {
|
||||
"name": "ask_user",
|
||||
"arguments": json.dumps(
|
||||
{
|
||||
"question": "Regenerate the script?",
|
||||
"options": ["Yes, replace", "No, keep"],
|
||||
"scope": "destructive_replace",
|
||||
}
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return gen()
|
||||
|
||||
class _Client:
|
||||
def chat_stream(self, messages, temperature=0.4, tools=None):
|
||||
return _AskStream()
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
from calliope.agent.harness import loop as loop_mod
|
||||
|
||||
orig_llm = loop_mod._llm_for_role
|
||||
loop_mod._llm_for_role = lambda role: _Client()
|
||||
ctx = ToolContext(session_id=session["id"], project_id=None)
|
||||
try:
|
||||
history: list = []
|
||||
asyncio.run(run_turn(ctx, history, max_iterations=5))
|
||||
finally:
|
||||
loop_mod._llm_for_role = orig_llm
|
||||
|
||||
events = session_log.read_events(session["id"])
|
||||
types = [e.type for e in events]
|
||||
assert session_log.TOOL_RESULT in types, types
|
||||
tr_idx = max(i for i, t in enumerate(types) if t == session_log.TOOL_RESULT)
|
||||
end_idx = max(i for i, t in enumerate(types) if t == session_log.TURN_END)
|
||||
assert tr_idx < end_idx, "tool/result must precede turn/end"
|
||||
tr = events[tr_idx]
|
||||
assert tr.data["tool_name"] == "ask_user"
|
||||
assert tr.data["result"]["options"] == ["Yes, replace", "No, keep"]
|
||||
assert tr.data["result"]["question"] == "Regenerate the script?"
|
||||
end = events[end_idx]
|
||||
assert end.data["status"] == "awaiting_input", end.data
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
|
||||
interface OpenQuestion {
|
||||
questionSeq: number;
|
||||
question: string;
|
||||
options: string[];
|
||||
scope: string;
|
||||
}
|
||||
@@ -138,12 +139,14 @@
|
||||
const r = m.tool_result as {
|
||||
ok?: boolean;
|
||||
question_seq?: number;
|
||||
question?: string;
|
||||
options?: unknown;
|
||||
scope?: string;
|
||||
};
|
||||
if (r.ok !== false && Array.isArray(r.options)) {
|
||||
return {
|
||||
questionSeq: r.question_seq ?? m.id,
|
||||
question: r.question ?? '',
|
||||
options: r.options as string[],
|
||||
scope: r.scope ?? 'info',
|
||||
};
|
||||
@@ -246,20 +249,25 @@
|
||||
/>
|
||||
{#if m.tool_name === 'ask_user' && lastQuestion && m.id === messages.findLast((x) => x.role === 'tool' && x.tool_name === 'ask_user')?.id}
|
||||
<div class="question-card">
|
||||
{#each lastQuestion.options as opt (opt)}
|
||||
<button
|
||||
type="button"
|
||||
class="question-option"
|
||||
class:affirmative={opt.toLowerCase().startsWith('yes')}
|
||||
disabled={running}
|
||||
onclick={() => onAnswer?.(opt, lastQuestion.scope, lastQuestion.questionSeq)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
{/each}
|
||||
{#if answerLabel(lastQuestion.scope)}
|
||||
<p class="question-scope">{answerLabel(lastQuestion.scope)}</p>
|
||||
{#if lastQuestion.question}
|
||||
<p class="question-text">{lastQuestion.question}</p>
|
||||
{/if}
|
||||
<div class="question-options">
|
||||
{#each lastQuestion.options as opt (opt)}
|
||||
<button
|
||||
type="button"
|
||||
class="question-option"
|
||||
class:affirmative={/^yes\b|^ok\b|^sure\b|^go\b|^do it\b|^confirm\b/i.test(opt)}
|
||||
disabled={running}
|
||||
onclick={() => onAnswer?.(opt, lastQuestion.scope, lastQuestion.questionSeq)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="question-hint">
|
||||
{answerLabel(lastQuestion.scope) || 'Pick an option or just type your answer.'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if m.tool_name && ENQUEUE_TOOLS.has(m.tool_name)}
|
||||
@@ -508,29 +516,45 @@
|
||||
}
|
||||
.question-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 4px 2px 2px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
margin: 2px 0 6px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--accent) 7%, var(--bg-surface));
|
||||
}
|
||||
.question-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.question-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.question-option {
|
||||
border: 1px solid color-mix(in srgb, var(--agent, var(--accent)) 45%, transparent);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border-radius: 999px;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
border-color 120ms ease,
|
||||
transform 120ms ease;
|
||||
border-color 120ms ease;
|
||||
}
|
||||
.question-option:hover:enabled {
|
||||
background: color-mix(in srgb, var(--agent, var(--accent)) 18%, var(--bg-elevated));
|
||||
border-color: var(--agent, var(--accent));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.question-option:disabled {
|
||||
opacity: 0.5;
|
||||
@@ -540,8 +564,7 @@
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.question-scope {
|
||||
width: 100%;
|
||||
.question-hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
|
||||
Reference in New Issue
Block a user