Skip to content

Commit a9bd2ca

Browse files
committed
feat(ask-user): Add configurable timeout controls
- Replace the model-supplied timeout_seconds parameter with ai.user_question_timeout_secs, defaulting to 180 seconds. - Support unlimited waits with null or zero and omit the default from persisted config while keeping it readable through the API. - Add question timeout settings under tool execution. - Synchronize host deadlines through live events and snapshots, and use a calibrated monotonic countdown for display. - Add a clickable countdown with a dismiss icon and settings hint; hide it only after the host acknowledges interaction. - Keep timeout decisions on the host and return English timeout text. - Prevent unchanged NumberInput values and Enter/blur sequences from triggering redundant saves; preserve Escape cancellation. - Add regression coverage for timing, persistence, and interactions.
1 parent 78daa02 commit a9bd2ca

26 files changed

Lines changed: 572 additions & 132 deletions

File tree

‎design-system/packages/ui/src/components/NumberInput/NumberInput.tsx‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,21 +72,25 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(functi
7272
const [draft, setDraft] = useState(() => format(value));
7373
const [editing, setEditing] = useState(false);
7474
const compositionActiveRef = useRef(false);
75+
const skipBlurCommitRef = useRef(false);
7576

7677
useEffect(() => { if (!editing) setDraft(format(value)); }, [editing, format, value]);
7778

7879
const commit = useCallback(() => {
7980
const parsed = Number.parseFloat(draft);
8081
if (Number.isFinite(parsed)) {
8182
const next = clamp(parsed);
82-
onValueChange(next);
83+
if (next !== value) onValueChange(next);
8384
setDraft(format(next));
8485
} else {
8586
setDraft(format(value));
8687
}
8788
setEditing(false);
8889
}, [clamp, draft, format, onValueChange, value]);
89-
const changeBy = (amount: number) => onValueChange(clamp(value + amount));
90+
const changeBy = (amount: number) => {
91+
const next = clamp(value + amount);
92+
if (next !== value) onValueChange(next);
93+
};
9094
return (
9195
<span className={classNames(styles.root, className)} data-openbitfun-component="number-input" data-disabled={disabled ? "true" : "false"} data-field-surface={fieldSurface} data-size={size} data-variant={variant}>
9296
{label && <span className={styles.label} data-openbitfun-part="label">{label}</span>}
@@ -112,7 +116,8 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(functi
112116
inputMode="decimal"
113117
onBlur={(event) => {
114118
onBlur?.(event);
115-
if (!event.defaultPrevented) commit();
119+
if (!event.defaultPrevented && !skipBlurCommitRef.current) commit();
120+
skipBlurCommitRef.current = false;
116121
}}
117122
onChange={(event) => setDraft(event.currentTarget.value)}
118123
onCompositionEnd={(event) => {
@@ -132,8 +137,8 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(functi
132137
if ((event.key === "Enter" || event.key === "Escape") && isImeOwnedKeyboardEvent(event, compositionActiveRef.current)) { event.stopPropagation(); return; }
133138
if (event.key === "ArrowUp") { event.preventDefault(); changeBy(step); }
134139
if (event.key === "ArrowDown") { event.preventDefault(); changeBy(-step); }
135-
if (event.key === "Enter") { commit(); event.currentTarget.blur(); }
136-
if (event.key === "Escape") { setDraft(format(value)); setEditing(false); event.currentTarget.blur(); }
140+
if (event.key === "Enter") { event.currentTarget.blur(); }
141+
if (event.key === "Escape") { skipBlurCommitRef.current = true; setDraft(format(value)); setEditing(false); event.currentTarget.blur(); }
137142
}}
138143
ref={ref}
139144
type="text"

‎design-system/packages/ui/src/flow-chat/ask-user/AskUser.module.css‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
font-weight: var(--openbitfun-type-label-lg-font-weight);
3434
}
3535

36+
.headerTrailing {
37+
margin-inline-start: auto;
38+
flex-shrink: 0;
39+
font-variant-numeric: tabular-nums;
40+
}
41+
3642
.summaryButton {
3743
display: flex;
3844
inline-size: 100%;

‎design-system/packages/ui/src/flow-chat/ask-user/AskUser.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export interface AskUserProps
6868
disabled?: boolean;
6969
expanded?: boolean;
7070
header?: ReactNode;
71+
headerTrailing?: ReactNode;
7172
onAnswersChange?: (questionId: string, values: readonly string[]) => void;
7273
onCustomAnswerChange?: (
7374
questionId: string,
@@ -128,6 +129,7 @@ export const AskUser = forwardRef<HTMLDivElement, AskUserProps>(function AskUser
128129
disabled = false,
129130
expanded,
130131
header,
132+
headerTrailing,
131133
onAnswersChange,
132134
onCustomAnswerChange,
133135
onExpandedChange,
@@ -254,7 +256,10 @@ export const AskUser = forwardRef<HTMLDivElement, AskUserProps>(function AskUser
254256
</span>
255257
</button>
256258
) : header !== undefined && header !== null ? (
257-
<div className={styles.header} data-openbitfun-part="header">{header}</div>
259+
<div className={styles.header} data-openbitfun-part="header">
260+
<OverflowText>{header}</OverflowText>
261+
{headerTrailing !== undefined && <span className={styles.headerTrailing}>{headerTrailing}</span>}
262+
</div>
258263
) : null}
259264

260265
<div

‎src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs‎

Lines changed: 62 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ use log::{debug, warn};
77
use openbitfun_agent_runtime::user_questions::{
88
ask_user_question_available_in_context, build_answered_user_question_result,
99
build_cancelled_user_question_result, build_timed_out_user_question_result,
10-
validate_ask_user_question_input, wait_for_user_question_response, AskUserQuestionInput,
10+
validate_ask_user_question_input, wait_for_user_question_response_until, AskUserQuestionInput,
1111
PendingUserQuestion, UserQuestionController, UserQuestionWaitOutcome,
12-
DEFAULT_USER_QUESTION_TIMEOUT_SECONDS, USER_INPUT_AVAILABLE_CONTEXT_KEY,
13-
USER_INPUT_MODEL_ROUND_CONTEXT_KEY, USER_INPUT_PARENT_CONTEXT_KEY,
12+
USER_INPUT_AVAILABLE_CONTEXT_KEY, USER_INPUT_MODEL_ROUND_CONTEXT_KEY,
13+
USER_INPUT_PARENT_CONTEXT_KEY,
1414
};
1515
use serde_json::{json, Value};
1616
use uuid::Uuid;
@@ -143,8 +143,6 @@ RECOMMENDATION GUIDELINES:
143143
- Provide 2-4 clear options with descriptions of trade-offs
144144
145145
Usage notes:
146-
- This tool waits up to 30 seconds by default for the first user interaction. Once the user clicks an option or input, the timeout is disabled and the tool waits for submission or cancellation. If the user does not interact, it skips the questions and returns so you can continue execution.
147-
- Prefer the default timeout. Set timeout_seconds only when necessary to wait a shorter or longer time. A timeout is not user approval or a selected answer.
148146
- Put all questions you need into a single AskUserQuestion call instead of calling it repeatedly in one response
149147
- Users will always be able to select "Other" to provide custom text input
150148
- Use multiSelect: true to allow multiple answers to be selected for a question"#.to_string())
@@ -158,13 +156,6 @@ Usage notes:
158156
json!({
159157
"type": "object",
160158
"properties": {
161-
"timeout_seconds": {
162-
"type": "integer",
163-
"minimum": 1,
164-
"maximum": u32::MAX,
165-
"default": DEFAULT_USER_QUESTION_TIMEOUT_SECONDS,
166-
"description": "Seconds to wait for the first user interaction, default 30. Once the user starts answering, wait until submission or cancellation without a timeout. Prefer omitting this parameter; use a shorter or longer wait only when necessary. On timeout, skip the questions and continue execution."
167-
},
168159
"questions": {
169160
"type": "array",
170161
"items": {
@@ -248,6 +239,24 @@ Usage notes:
248239
&self,
249240
input: &Value,
250241
context: &ToolUseContext,
242+
) -> OpenBitFunResult<Vec<ToolResult>> {
243+
let service = crate::service::config::global::GlobalConfigManager::get_service().await?;
244+
let ai_config: crate::service::config::types::AIConfig =
245+
service.get_config(Some("ai")).await?;
246+
let timeout = ai_config
247+
.user_question_timeout_secs
248+
.filter(|seconds| *seconds > 0)
249+
.map(|seconds| std::time::Duration::from_secs(u64::from(seconds)));
250+
self.call_with_timeout(input, context, timeout).await
251+
}
252+
}
253+
254+
impl AskUserQuestionTool {
255+
async fn call_with_timeout(
256+
&self,
257+
input: &Value,
258+
context: &ToolUseContext,
259+
timeout: Option<std::time::Duration>,
251260
) -> OpenBitFunResult<Vec<ToolResult>> {
252261
if !Self::is_available_for_tool_context(Some(context)) {
253262
return Err(crate::util::errors::OpenBitFunError::tool(
@@ -287,7 +296,17 @@ Usage notes:
287296
.get(USER_INPUT_MODEL_ROUND_CONTEXT_KEY)
288297
.and_then(Value::as_str)
289298
.map(str::to_string);
290-
let questions = serde_json::to_value(&tool_input).unwrap_or_else(|_| json!({}));
299+
let mut questions = serde_json::to_value(&tool_input).unwrap_or_else(|_| json!({}));
300+
let controllers = Self::question_controllers(context).await;
301+
let wait_deadline = timeout.map(|duration| tokio::time::Instant::now() + duration);
302+
let registered_at = std::time::SystemTime::now()
303+
.duration_since(std::time::UNIX_EPOCH)
304+
.unwrap_or_default()
305+
.as_millis() as u64;
306+
questions["responseHostNowMs"] = json!(registered_at);
307+
questions["responseDeadlineMs"] = timeout
308+
.map(|duration| json!(registered_at.saturating_add(duration.as_millis() as u64)))
309+
.unwrap_or(Value::Null);
291310

292311
// 4. Create oneshot channel
293312
let (tx, rx) = tokio::sync::oneshot::channel();
@@ -305,7 +324,7 @@ Usage notes:
305324
questions.clone(),
306325
),
307326
tx,
308-
Self::question_controllers(context).await,
327+
controllers,
309328
);
310329

311330
// 6. Send backend event to notify frontend to display question card
@@ -326,13 +345,7 @@ Usage notes:
326345

327346
// 7. Bound the wait in the runtime host, including for remote driving surfaces.
328347
// The registration guard clears replay state on timeout or turn cancellation.
329-
match wait_for_user_question_response(
330-
&registration,
331-
rx,
332-
std::time::Duration::from_secs(u64::from(tool_input.timeout_seconds)),
333-
)
334-
.await
335-
{
348+
match wait_for_user_question_response_until(&registration, rx, wait_deadline).await {
336349
UserQuestionWaitOutcome::Answered(response) => {
337350
debug!(
338351
"AskUserQuestion tool received user response, tool_id: {}",
@@ -444,7 +457,7 @@ mod tests {
444457

445458
let error = tokio::time::timeout(
446459
std::time::Duration::from_millis(100),
447-
tool.call(&input, &context),
460+
tool.call_with_timeout(&input, &context, Some(std::time::Duration::from_secs(180))),
448461
)
449462
.await
450463
.expect("non-interactive question must not wait")
@@ -495,7 +508,8 @@ mod tests {
495508
}]
496509
});
497510

498-
let call = tool.call(&input, &context);
511+
let call =
512+
tool.call_with_timeout(&input, &context, Some(std::time::Duration::from_secs(180)));
499513
tokio::pin!(call);
500514
let mailbox_registration = async {
501515
loop {
@@ -505,7 +519,16 @@ mod tests {
505519
assert_eq!(question.dialog_turn_id.as_deref(), Some(turn_id.as_str()));
506520
assert_eq!(question.model_round_id.as_deref(), Some(round_id.as_str()));
507521
assert_eq!(
508-
question.questions,
522+
{
523+
let mut payload = question.questions.clone();
524+
payload.as_object_mut().unwrap().remove("responseHostNowMs");
525+
assert!(payload["responseDeadlineMs"].is_u64());
526+
payload
527+
.as_object_mut()
528+
.unwrap()
529+
.remove("responseDeadlineMs");
530+
payload
531+
},
509532
serde_json::json!({
510533
"questions": [{
511534
"question": "Continue?",
@@ -557,13 +580,16 @@ mod tests {
557580
)]));
558581
context.session_id = Some(child.clone());
559582
context.tool_call_id = Some(unique.clone());
560-
let input = serde_json::json!({"timeout_seconds": 1, "questions": [{
583+
let input = serde_json::json!({"questions": [{
561584
"question": "Continue?", "header": "Continue", "options": [
562585
{"label": "Yes", "description": "Continue"}, {"label": "No", "description": "Stop"}
563586
]
564587
}]});
565-
let task =
566-
tokio::spawn(async move { AskUserQuestionTool::new().call(&input, &context).await });
588+
let task = tokio::spawn(async move {
589+
AskUserQuestionTool::new()
590+
.call_with_timeout(&input, &context, Some(std::time::Duration::from_secs(180)))
591+
.await
592+
});
567593
let manager = get_user_input_manager();
568594
tokio::time::timeout(std::time::Duration::from_secs(2), async {
569595
while !manager.has_pending(&unique) {
@@ -595,21 +621,19 @@ mod tests {
595621
}
596622

597623
#[test]
598-
fn timeout_schema_is_optional_and_owned_by_the_tool() {
624+
fn timeout_is_configured_outside_the_tool_schema() {
599625
let tool = AskUserQuestionTool::new();
600626
let schema = tool.input_schema();
601-
assert_eq!(schema["properties"]["timeout_seconds"]["default"], 30);
602-
assert_eq!(schema["properties"]["timeout_seconds"]["minimum"], 1);
627+
assert!(schema["properties"].get("timeout_seconds").is_none());
603628
assert_eq!(schema["required"], serde_json::json!(["questions"]));
604629
assert!(tool.manages_own_execution_timeout());
605630
}
606-
607631
async fn assert_question_times_out(timeout_seconds: Option<u32>) {
608632
let unique = uuid::Uuid::new_v4().to_string();
609633
let mut context = context_with_custom_data(HashMap::new());
610634
context.session_id = Some(unique.clone());
611635
context.tool_call_id = Some(unique.clone());
612-
let mut input = serde_json::json!({
636+
let input = serde_json::json!({
613637
"questions": [{
614638
"question": "Continue?", "header": "Continue",
615639
"options": [
@@ -618,14 +642,11 @@ mod tests {
618642
]
619643
}]
620644
});
621-
if let Some(seconds) = timeout_seconds {
622-
input["timeout_seconds"] = serde_json::json!(seconds);
623-
}
624-
let expected = std::time::Duration::from_secs(u64::from(timeout_seconds.unwrap_or(30)));
645+
let expected = std::time::Duration::from_secs(u64::from(timeout_seconds.unwrap_or(180)));
625646
let started = tokio::time::Instant::now();
626647
let result = tokio::time::timeout(
627648
expected + std::time::Duration::from_secs(5),
628-
AskUserQuestionTool::new().call(&input, &context),
649+
AskUserQuestionTool::new().call_with_timeout(&input, &context, Some(expected)),
629650
)
630651
.await
631652
.expect("question must finish without a user response")
@@ -640,7 +661,7 @@ mod tests {
640661
assert_eq!(data["status"], "timeout");
641662
assert_eq!(
642663
result_for_assistant.as_deref(),
643-
Some("用户无响应,跳过提问,继续执行")
664+
Some("The user did not respond before the timeout. Skip the questions and continue execution.")
644665
);
645666
}
646667
_ => panic!("timeout must return a normal tool result"),
@@ -657,8 +678,8 @@ mod tests {
657678
}
658679

659680
#[tokio::test]
660-
async fn unanswered_question_defaults_to_thirty_seconds_and_rejects_late_answers() {
661-
assert_question_times_out(None).await;
681+
async fn unanswered_question_times_out_and_rejects_late_answers() {
682+
assert_question_times_out(Some(1)).await;
662683
}
663684

664685
#[tokio::test]
@@ -667,6 +688,6 @@ mod tests {
667688
}
668689
#[tokio::test]
669690
async fn unanswered_question_honors_longer_timeout() {
670-
assert_question_times_out(Some(31)).await;
691+
assert_question_times_out(Some(2)).await;
671692
}
672693
}

‎src/crates/assembly/core/src/service/config/manager.rs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ fn config_value_for_persistence(config: &GlobalConfig) -> OpenBitFunResult<Value
8787
.map_err(|e| OpenBitFunError::config(format!("Failed to serialize config: {}", e)))?;
8888
prune_default_ai_tool_argument_json_repair(&mut value);
8989
prune_default_ai_max_rounds(&mut value);
90+
if config.ai.user_question_timeout_secs == AIConfig::default().user_question_timeout_secs {
91+
if let Some(ai) = value.get_mut("ai").and_then(Value::as_object_mut) {
92+
ai.remove("user_question_timeout_secs");
93+
}
94+
}
9095
prune_default_memories_config(&mut value)?;
9196
prune_default_web_search_config(&mut value)?;
9297
Ok(value)
@@ -1011,6 +1016,28 @@ mod tests {
10111016
}
10121017
}
10131018

1019+
#[test]
1020+
fn user_question_timeout_default_is_readable_but_not_persisted() {
1021+
let mut config = GlobalConfig::default();
1022+
assert_eq!(
1023+
serde_json::to_value(&config).unwrap()["ai"]["user_question_timeout_secs"],
1024+
180
1025+
);
1026+
assert!(config_value_for_persistence(&config).unwrap()["ai"]
1027+
.get("user_question_timeout_secs")
1028+
.is_none());
1029+
for timeout in [None, Some(0), Some(60), Some(180)] {
1030+
config.ai.user_question_timeout_secs = timeout;
1031+
let persisted = config_value_for_persistence(&config).unwrap();
1032+
assert_eq!(
1033+
persisted["ai"].get("user_question_timeout_secs").is_none(),
1034+
timeout == Some(180)
1035+
);
1036+
let restored: GlobalConfig = serde_json::from_value(persisted).unwrap();
1037+
assert_eq!(restored.ai.user_question_timeout_secs, timeout);
1038+
}
1039+
}
1040+
10141041
#[test]
10151042
fn current_config_contract_requires_openbitfun_identity_and_format() {
10161043
let current = serde_json::to_value(GlobalConfig::default()).unwrap();

0 commit comments

Comments
 (0)