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:
Benjiyaya
2026-09-07 05:36:05 +08:00
parent 5136e24132
commit dc5421c4a5
4 changed files with 161 additions and 37 deletions
@@ -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);