From a751647244d84354d629328e11a1a5f7a74fbff7 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Tue, 18 Aug 2026 18:18:20 -0400 Subject: [PATCH] =?UTF-8?q?interactions:=20add=20the=20choices=20kind=20(A?= =?UTF-8?q?skUserQuestion)=20=E2=80=94=20Rust=20reference=20+=20shared=20s?= =?UTF-8?q?chema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `choices`, the second Rich Interaction kind, modeled on Claude Code's AskUserQuestion: the agent raises 1–4 structured multiple-choice questions and the turn parks until the visitor picks. - Rust kind (`rust/smooth-operator/src/choices.rs`): `ChoicesKind` implementing the `InteractionKind` seam — `request_choices { questions, reason }` raise tool, `validate_choices` server-side validator (every question answered, each label offered, single vs multiSelect, always-available free-text `other` escape hatch), and the enumerated conversational fallback directive. Capability id `choice_chips`. Registered in the default `InteractionRegistry`. - Shared contract (`spec/interactions/choices.schema.json`): Spec / Values / Payload $defs the other four servers + web SDK mirror, plus conformance fixtures. Envelope stays generic (no per-kind enums). - Tests: validator unit tests (single/multi/other/invalid) + a park/resume WS integration test (rich card path + capability-less conversational fallback). - Docs: Rich Interactions + Protocol Reference updated; `choices` moved from candidate to implemented (Rust). Changeset added. Wave 1 of the polyglot effort — this is the reference the ports follow. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YbN45JeWDbcjvFqGJvmVD3 --- .changeset/choices-interaction-kind.md | 9 + docs/Architecture/Rich Interactions.md | 28 +- docs/Reference/Protocol Reference.md | 2 +- .../tests/choices_interaction.rs | 441 ++++++++++++ rust/smooth-operator/src/choices.rs | 650 ++++++++++++++++++ rust/smooth-operator/src/interaction.rs | 16 +- rust/smooth-operator/src/lib.rs | 5 + .../create-conversation-session.schema.json | 2 +- spec/conformance/fixtures.json | 49 ++ spec/interactions/choices.schema.json | 146 ++++ 10 files changed, 1333 insertions(+), 15 deletions(-) create mode 100644 .changeset/choices-interaction-kind.md create mode 100644 rust/smooth-operator-server/tests/choices_interaction.rs create mode 100644 rust/smooth-operator/src/choices.rs create mode 100644 spec/interactions/choices.schema.json diff --git a/.changeset/choices-interaction-kind.md b/.changeset/choices-interaction-kind.md new file mode 100644 index 00000000..4589af54 --- /dev/null +++ b/.changeset/choices-interaction-kind.md @@ -0,0 +1,9 @@ +--- +'@smooai/smooth-operator': minor +--- + +Add the `choices` Rich Interaction kind (a structured multiple-choice ask, modeled on Claude Code's AskUserQuestion) as the second reference kind in the Rust implementation, plus its shared JSON-Schema contract the other servers + web SDK mirror. + +An agent raises `request_choices` with 1–4 questions, each `{ question, header (short ≤12-char label), options: [{ label, description }] (2–4), multiSelect? }` and a `reason`. On a channel that declared the **`choice_chips`** capability the turn parks and the client renders chips/menus (`interaction_required { kind: "choices" }`); on text/voice channels the same raise degrades to an enumerated conversational directive. Every question carries an implicit free-text `other` escape hatch (mirroring AskUserQuestion's ever-present "Other"), so the visitor can always answer outside the enumerated options. + +Server-side validation (`validate_choices`, shared by the card path's WS handler and the fallback path's `submit_interaction` tool): every question answered, each selected label offered, single-select takes exactly one pick (label XOR `other`), multi-select one or more; invalid submits return retryable per-question `interaction_invalid` errors, never a terminal error. `ChoicesKind` is registered in the default `InteractionRegistry`. The canonical contract lives in `spec/interactions/choices.schema.json` (Spec / Values / Payload) with conformance fixtures; the other-language servers follow as parity work. diff --git a/docs/Architecture/Rich Interactions.md b/docs/Architecture/Rich Interactions.md index 1f28e672..f2e94100 100644 --- a/docs/Architecture/Rich Interactions.md +++ b/docs/Architecture/Rich Interactions.md @@ -10,7 +10,7 @@ Agents constantly need **structured input** from the visitor mid-conversation A **Rich Interaction** is a typed, server-validated ask the agent raises mid-turn. On a channel whose client can render it, it appears as a **rich card** (inline form / picker / chips) and the turn parks until the visitor answers. On a text-only channel the SAME raise degrades to a **conversational fallback**: kind-specific instructions the model follows turn by turn, submitting through a generic validated tool. **Either way the turn resumes with the same canonical, server-validated payload** — the agent's downstream flow is channel-independent. -`identity_intake` (name/email/phone lead capture) is the first kind and the reference implementation. Candidate future kinds the shape is proven against on paper: **date/appointment picker**, **choice chips / menus**, **file upload**, **address input**, **rating / CSAT**, **payment handoff**, **e-sign**. +`identity_intake` (name/email/phone lead capture) is the first kind and the reference implementation; `choices` (a structured multiple-choice ask, modeled on Claude Code's AskUserQuestion) is the second (Rust). Candidate future kinds the shape is proven against on paper: **date/appointment picker**, **file upload**, **address input**, **rating / CSAT**, **payment handoff**, **e-sign**. ### Wire surface (generic envelope, typed kinds) @@ -55,15 +55,25 @@ Each kind names the capability that gates its rich path (`identity_intake` → ` All park/resume/event/registry machinery is shared and kind-agnostic. A kind supplies exactly what differs (`smooth_operator::interaction::InteractionKind`): -| Trait surface | Role | identity_intake | -| --- | --- | --- | -| `kind()` / `capability()` | identity | `identity_intake` / `identity_form` | -| `tool_schema()` + `parse_request()` | the per-kind **raise tool** (precise LLM parameter schema) → canonical `spec` + `reason` | `request_identity_intake { fields, reason }` | -| `validate(spec, values)` | **server-side validator** → canonical values or per-field errors (shared by the card path's WS handler and the fallback path's tool) | required fields, email shape, phone → E.164 | -| `fallback_directive(spec, reason)` | **conversational degradation** for text channels | "ask ONE field at a time … submit via `submit_interaction`" | +| Trait surface | Role | identity_intake | choices | +| --- | --- | --- | --- | +| `kind()` / `capability()` | identity | `identity_intake` / `identity_form` | `choices` / `choice_chips` | +| `tool_schema()` + `parse_request()` | the per-kind **raise tool** (precise LLM parameter schema) → canonical `spec` + `reason` | `request_identity_intake { fields, reason }` | `request_choices { questions, reason }` | +| `validate(spec, values)` | **server-side validator** → canonical values or per-field errors (shared by the card path's WS handler and the fallback path's tool) | required fields, email shape, phone → E.164 | every question answered, each label offered, single vs multi-select, free-text `other` accepted | +| `fallback_directive(spec, reason)` | **conversational degradation** for text channels | "ask ONE field at a time … submit via `submit_interaction`" | "enumerate each question + its options … submit via `submit_interaction`" | Fallback strategies are per kind by design: identity = field-by-field collect+validate; choices = enumerated ask; date = natural-language date accepted by the kind's validator. +### `choices` — a structured multiple-choice ask (AskUserQuestion) + +The second reference kind (Rust). The agent raises `request_choices` with **1–4 questions**, each `{ question, header (short ≤12-char label), options: [{ label, description }] (2–4), multiSelect? (default false) }` and a `reason`. Render capability: **`choice_chips`** — a client that declares it gets a parked chips/menu card; text/voice channels inherit the enumerated conversational fallback. + +- **Spec** (`spec/interactions/choices.schema.json#/$defs/Spec`): `{ questions: [{ question, header, options: [{label, description}], multiSelect }] }`. +- **Values** (what the client submits, `#/$defs/Values`): `{ answers: [{ header, options: [selected labels], other? }] }`. Every question is keyed by its `header`. An implicit free-text **`other`** escape hatch is always available (mirrors AskUserQuestion's ever-present "Other"), so a visitor can answer outside the enumerated options. +- **Validator** (`validate_choices`): every question must be answered (a selection or a non-blank `other`); each selected label must be one of that question's options; single-select takes exactly one pick (one label XOR `other`), multi-select one or more; blank `other` is dropped, labels trimmed. Invalid → `interaction_invalid` (retryable, per-question `field` = the `header`), never a terminal error. +- **Payload** (`#/$defs/Payload`): the framework-uniform `{ status, values: { answers }, message? }`. +- **Host effect**: none — `choices` collects an answer, it doesn't mutate the session (unlike `identity_intake`'s contact attach). + **Adding a kind = one module + three registrations:** 1. `spec/interactions/.schema.json` (Spec / Values / Payload `$defs`) + conformance fixtures; @@ -101,8 +111,8 @@ Intake = **collect** (who are you?), OTP = **verify** (prove it). Shared machine | Layer | Change | | ----- | ------ | -| `spec/` | `events/interaction-required.schema.json`, `events/interaction-invalid.schema.json`, `actions/submit-interaction.schema.json`, `spec/interactions/identity-intake.schema.json` (the kind catalog dir), `supports` on `create-conversation-session`, envelope enums, conformance fixtures | -| `rust/smooth-operator` | `interaction` module (the `InteractionKind` trait + `InteractionRegistry`); `identity_intake` module (validation + `IdentityIntakeKind`); `tools/interaction.rs` (generic raise wrapper + `submit_interaction` tool) | +| `spec/` | `events/interaction-required.schema.json`, `events/interaction-invalid.schema.json`, `actions/submit-interaction.schema.json`, `spec/interactions/identity-intake.schema.json` + `spec/interactions/choices.schema.json` (the kind catalog dir), `supports` on `create-conversation-session`, envelope enums, conformance fixtures (identity + choices) | +| `rust/smooth-operator` | `interaction` module (the `InteractionKind` trait + `InteractionRegistry`); `identity_intake` + `choices` modules (validation + `IdentityIntakeKind` / `ChoicesKind`, both in the default registry); `tools/interaction.rs` (generic raise wrapper + `submit_interaction` tool) | | `rust/smooth-operator-server` | generic protocol constructors, `pending_interactions` registry + `session_capabilities`, `submit_interaction` dispatch, runner wiring (`TurnRequest::interactions`), kind-routed attach seam | | `typescript/` (client) | regenerated types, `supports`, the single `submitInteraction()` resume verb | | chat-widget repo | card registry + the identity card; declares `supports` from the registry | diff --git a/docs/Reference/Protocol Reference.md b/docs/Reference/Protocol Reference.md index 9547fa6b..ef1b2fd2 100644 --- a/docs/Reference/Protocol Reference.md +++ b/docs/Reference/Protocol Reference.md @@ -130,7 +130,7 @@ Resolution goes through a host seam (`AppState::with_skill_resolver` / `LocalSer ## Rich Interactions (structured cards, channel-normalized) -A Rich Interaction is a typed, server-validated mid-turn ask (`interaction_required { interactionId, kind, spec, reason }` → the client card → `submit_interaction`). Sessions declare per-kind render capabilities in `supports` at create; kinds without their capability degrade to a **conversational fallback** (the raise tool returns kind-specific instructions and the model submits through the generic `submit_interaction` tool — same server-side validator, same canonical resume payload). The kind catalog lives in `spec/interactions/` (first kind: `identity_intake`, capability `identity_form` — name/email/phone with email-shape + E.164 validation, attaching to the session's OTP contact keys). Full design + extension recipe: [[Rich Interactions]]. +A Rich Interaction is a typed, server-validated mid-turn ask (`interaction_required { interactionId, kind, spec, reason }` → the client card → `submit_interaction`). Sessions declare per-kind render capabilities in `supports` at create; kinds without their capability degrade to a **conversational fallback** (the raise tool returns kind-specific instructions and the model submits through the generic `submit_interaction` tool — same server-side validator, same canonical resume payload). The kind catalog lives in `spec/interactions/` — `identity_intake` (capability `identity_form` — name/email/phone with email-shape + E.164 validation, attaching to the session's OTP contact keys) and `choices` (capability `choice_chips` — a structured 1–4-question multiple-choice ask à la AskUserQuestion, each question 2–4 options plus an implicit free-text "other"). Full design + extension recipe: [[Rich Interactions]]. ## Turn cancellation (the "Stop button") diff --git a/rust/smooth-operator-server/tests/choices_interaction.rs b/rust/smooth-operator-server/tests/choices_interaction.rs new file mode 100644 index 00000000..ec1a4dc9 --- /dev/null +++ b/rust/smooth-operator-server/tests/choices_interaction.rs @@ -0,0 +1,441 @@ +//! Rich Interactions — the `choices` kind (a structured `AskUserQuestion`), +//! exercised end-to-end through the real runner + handler. +//! +//! - **Rich path** (session declared the `choice_chips` capability): the turn +//! parks inside the `request_choices` raise, an `interaction_required` event +//! surfaces (kind `choices`, the questions spec), an invalid +//! `submit_interaction` (a label that isn't offered) gets `interaction_invalid` +//! and LEAVES the turn parked, a valid selection resumes the raise with the +//! canonical payload. +//! - **Conversational fallback** (no capability): the same raise returns the +//! kind's enumerated directive immediately (no park); the model's generic +//! `submit_interaction` tool call is validated server-side and returns the +//! IDENTICAL canonical payload. +//! +//! Runs fully offline (`MockLlmClient` scripts the tool calls). + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; + +use smooth_operator::access_control::AccessContext; +use smooth_operator::adapter::StorageAdapter; +use smooth_operator::domain::{Session, SessionStatus}; +use smooth_operator_adapter_memory::InMemoryStorageAdapter; +use smooth_operator_core::llm::StreamEvent; +use smooth_operator_core::llm_provider::MockLlmClient; +use smooth_operator_core::LlmConfig; + +use smooth_operator::interaction::InteractionRegistry; +use smooth_operator_server::config::{ServerConfig, StorageBackend}; +use smooth_operator_server::handler; +use smooth_operator_server::runner::{self, InteractionConfig, TurnRequest}; +use smooth_operator_server::state::AppState; +use smooth_operator_server::state::PendingInteraction; + +const SESSION_ID: &str = "sess-choices-1"; +const CONVERSATION_ID: &str = "conv-choices-1"; +const REQUEST_ID: &str = "req-choices-1"; + +fn mock_llm() -> LlmConfig { + LlmConfig::openrouter("not-a-real-key").with_model("openai/gpt-4o") +} + +fn config() -> ServerConfig { + ServerConfig { + bind: "127.0.0.1".into(), + port: 0, + gateway_url: "https://example.invalid/v1".into(), + gateway_key: None, + model: "claude-haiku-4-5".into(), + seed_kb: false, + max_iterations: 6, + max_tokens: 128, + storage: StorageBackend::Memory, + widget_auth_strict: false, + confirm_tools: Vec::new(), + judge_model: "claude-haiku-4-5".to_string(), + } +} + +fn test_session() -> Session { + let now = chrono::Utc::now(); + Session { + session_id: SESSION_ID.to_string(), + conversation_id: CONVERSATION_ID.to_string(), + organization_id: "org".to_string(), + agent_id: Some("agent".to_string()), + agent_name: "Agent".to_string(), + user_participant_id: "u".to_string(), + agent_participant_id: "a".to_string(), + thread_id: CONVERSATION_ID.to_string(), + status: Some(SessionStatus::Active), + token_count: Some(0), + message_count: Some(0), + metadata: None, + created_at: Some(now), + updated_at: Some(now), + ended_at: None, + last_activity_at: Some(now), + } +} + +/// The interactions wiring the WS handler builds, over a real `AppState`. +/// `choices` has no host attach effect, so the attach callback is a no-op. +fn interactions_for(state: &AppState, capabilities: &[&str]) -> InteractionConfig { + InteractionConfig { + session_id: SESSION_ID.to_string(), + kinds: Arc::new(InteractionRegistry::default()), + capabilities: capabilities.iter().map(|s| (*s).to_string()).collect(), + register: { + let state = state.clone(); + Arc::new( + move |sid: &str, interaction_id: &str, kind: &str, spec: &Value, responder| { + state.register_interaction( + sid, + PendingInteraction { + interaction_id: interaction_id.to_string(), + kind: kind.to_string(), + spec: spec.clone(), + responder, + }, + ); + }, + ) + }, + clear: { + let state = state.clone(); + Arc::new(move |sid: &str| state.clear_interaction(sid)) + }, + attach: Arc::new(|_kind, _values| {}), + } +} + +/// The two-question `request_choices` raise the mocks share (Plan single-select, +/// Topics multi-select). +const RAISE_ARGS: &str = r#"{"questions":[ + {"question":"Which plan interests you?","header":"Plan","options":[{"label":"Basic","description":"For individuals"},{"label":"Pro","description":"For teams"}]}, + {"question":"What can we help with?","header":"Topics","options":[{"label":"Sales"},{"label":"Support"}],"multiSelect":true} +],"reason":"to route you to the right team"}"#; + +/// Turn-1 raises `request_choices`, then (turn-2) answers. +fn raising_mock() -> MockLlmClient { + let mock = MockLlmClient::new(); + mock.push_stream(vec![ + StreamEvent::ToolCallStart { + index: 0, + id: "call_1".into(), + name: "request_choices".into(), + }, + StreamEvent::ToolCallArgumentsDelta { + index: 0, + arguments_chunk: RAISE_ARGS.into(), + }, + StreamEvent::Done { + finish_reason: "tool_calls".into(), + }, + ]) + .push_stream(vec![ + StreamEvent::Delta { + content: "Great, routing you now.".into(), + }, + StreamEvent::Done { + finish_reason: "stop".into(), + }, + ]); + mock +} + +fn spawn_turn( + state: AppState, + storage: Arc, + mock: MockLlmClient, + capabilities: &[&str], + sink: UnboundedSender, +) -> tokio::task::JoinHandle { + let interactions = interactions_for(&state, capabilities); + tokio::spawn(async move { + runner::run_streaming_turn( + TurnRequest { + demo_tools: false, + storage, + llm: mock_llm(), + max_iterations: 6, + conversation_id: CONVERSATION_ID, + request_id: REQUEST_ID, + user_message: "hi", + model_max_output: None, + access: AccessContext::anonymous(), + llm_provider: Some(Arc::new(mock)), + executor: None, + reranker: None, + confirmation: None, + interactions: Some(interactions), + tool_provider: None, + tool_hooks: vec![], + system_prompt: None, + org_id: None, + gateway_key: None, + workflow: None, + judge: None, + greeting_section: None, + skill_section: None, + enabled_tools: None, + auth_gate: None, + tool_configs: None, + extensions: None, + images: vec![], + files: vec![], + request_metadata: None, + }, + &sink, + ) + .await + .expect("run_streaming_turn") + }) +} + +async fn await_event(rx: &mut UnboundedReceiver, wanted: &str) -> (Value, Vec) { + let mut seen = Vec::new(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + match tokio::time::timeout(remaining, rx.recv()).await { + Ok(Some(ev)) => { + let hit = ev["type"] == wanted; + seen.push(ev.clone()); + if hit { + return (ev, seen); + } + } + Ok(None) => panic!("sink closed before '{wanted}'; saw: {seen:?}"), + Err(_) => panic!("timed out waiting for '{wanted}'; saw: {seen:?}"), + } + } +} + +fn drain_into(rx: &mut UnboundedReceiver, seen: &mut Vec) { + while let Ok(ev) = rx.try_recv() { + seen.push(ev); + } +} + +fn tool_result_text(events: &[Value]) -> String { + let mut s = String::new(); + for ev in events { + if let Some(result) = ev + .pointer("/data/state/rawResponse/toolResult/result") + .and_then(Value::as_str) + { + s.push_str(result); + s.push('\n'); + } + } + s +} + +async fn submit_frame(state: &AppState, sink: &UnboundedSender, body: Value) { + handler::handle_frame( + state, + &AccessContext::anonymous(), + "conn-1", + None, + None, + &smooth_operator_server::handler::UserScope::Unscoped, + &body.to_string(), + sink, + ) + .await; +} + +#[tokio::test] +async fn rich_path_parks_validates_and_resumes_with_the_canonical_payload() { + let storage = Arc::new(InMemoryStorageAdapter::new()); + let state = AppState::new(storage.clone(), config()); + state.insert_session(test_session()); + let (tx, mut rx) = unbounded_channel::(); + + let turn = spawn_turn( + state.clone(), + storage as Arc, + raising_mock(), + &["choice_chips"], + tx.clone(), + ); + + // 1. The turn parks and the spec-shaped event surfaces. + let (pending, mut seen) = await_event(&mut rx, "interaction_required").await; + assert_eq!(pending["requestId"], REQUEST_ID); + let inner = &pending["data"]["data"]; + assert_eq!(inner["kind"], "choices"); + assert_eq!(inner["reason"], "to route you to the right team"); + assert_eq!(inner["spec"]["questions"][0]["header"], "Plan"); + assert_eq!( + inner["spec"]["questions"][0]["options"][0]["label"], + "Basic" + ); + assert_eq!(inner["spec"]["questions"][1]["multiSelect"], true); + let interaction_id = inner["interactionId"] + .as_str() + .expect("interactionId") + .to_string(); + + // 2. An INVALID submit (a label that isn't offered) → interaction_invalid, + // and the turn STAYS parked. + submit_frame( + &state, + &tx, + json!({ + "action": "submit_interaction", + "requestId": REQUEST_ID, + "sessionId": SESSION_ID, + "interactionId": interaction_id, + "kind": "choices", + "values": { "answers": [ + { "header": "Plan", "options": ["Platinum"] }, + { "header": "Topics", "options": ["Sales"] } + ] } + }), + ) + .await; + let (invalid, _) = await_event(&mut rx, "interaction_invalid").await; + assert_eq!(invalid["data"]["data"]["kind"], "choices"); + assert_eq!(invalid["data"]["data"]["errors"][0]["field"], "Plan"); + assert!( + state.pending_interaction(SESSION_ID).is_some(), + "invalid submit must leave the turn parked for a resubmit" + ); + + // 3. A VALID submit (single-select Plan + multi-select Topics + an 'Other') + // → ack + the parked raise resumes with canonical values. + submit_frame( + &state, + &tx, + json!({ + "action": "submit_interaction", + "requestId": REQUEST_ID, + "sessionId": SESSION_ID, + "interactionId": interaction_id, + "values": { "answers": [ + { "header": "Plan", "options": ["Pro"] }, + { "header": "Topics", "options": ["Sales"], "other": "Partnerships" } + ] } + }), + ) + .await; + + let result = tokio::time::timeout(Duration::from_secs(5), turn) + .await + .expect("turn should complete after submit") + .expect("turn task"); + drain_into(&mut rx, &mut seen); + + let tool_text = tool_result_text(&seen); + assert!( + tool_text.contains(r#""status":"submitted""#), + "tool result should be the canonical payload, got: {tool_text}" + ); + assert!( + tool_text.contains("Pro") && tool_text.contains("Partnerships"), + "canonical answers reach the model: {tool_text}" + ); + assert_eq!(result.reply, "Great, routing you now."); + + // The ack + park consumed. + let acked = seen + .iter() + .any(|ev| ev["type"] == "immediate_response" && ev["message"] == "Interaction submitted"); + assert!(acked, "valid submit is acked: {seen:?}"); + assert!( + state.pending_interaction(SESSION_ID).is_none(), + "park consumed" + ); +} + +#[tokio::test] +async fn text_channel_degrades_to_validated_conversational_collection() { + let storage = Arc::new(InMemoryStorageAdapter::new()); + let state = AppState::new(storage.clone(), config()); + state.insert_session(test_session()); + let (tx, mut rx) = unbounded_channel::(); + + // Scripted conversation: raise → (directive) → submit good answers → + // (payload) → final answer. + let mock = MockLlmClient::new(); + mock.push_stream(vec![ + StreamEvent::ToolCallStart { + index: 0, + id: "call_1".into(), + name: "request_choices".into(), + }, + StreamEvent::ToolCallArgumentsDelta { + index: 0, + arguments_chunk: RAISE_ARGS.into(), + }, + StreamEvent::Done { + finish_reason: "tool_calls".into(), + }, + ]) + .push_stream(vec![ + StreamEvent::ToolCallStart { + index: 0, + id: "call_2".into(), + name: "submit_interaction".into(), + }, + StreamEvent::ToolCallArgumentsDelta { + index: 0, + arguments_chunk: r#"{"kind":"choices","values":{"answers":[{"header":"Plan","options":["Basic"]},{"header":"Topics","options":["Support","Sales"]}]}}"#.into(), + }, + StreamEvent::Done { + finish_reason: "tool_calls".into(), + }, + ]) + .push_stream(vec![ + StreamEvent::Delta { + content: "All set.".into(), + }, + StreamEvent::Done { + finish_reason: "stop".into(), + }, + ]); + + let turn = spawn_turn( + state.clone(), + storage as Arc, + mock, + &[], // no capabilities → conversational fallback for every kind + tx, + ); + + let result = tokio::time::timeout(Duration::from_secs(5), turn) + .await + .expect("turn completes without any park") + .expect("turn task"); + + let mut seen = Vec::new(); + drain_into(&mut rx, &mut seen); + + // No form/card event on a text channel. + assert!( + seen.iter().all(|ev| ev["type"] != "interaction_required"), + "text channels must not receive the card event: {seen:?}" + ); + + let tool_text = tool_result_text(&seen); + // 1. The raise degraded to the enumerated conversational directive (the + // core mirror truncates long tool results, so assert on its leading + // content, not the trailing `submit_interaction` instruction). + assert!( + tool_text.contains("cannot display choice chips"), + "directive returned: {tool_text}" + ); + // 2. The good submit produced the SAME validated payload as the card path. + assert!( + tool_text.contains(r#""status":"submitted""#), + "conversational submit produces the canonical payload: {tool_text}" + ); + assert!(tool_text.contains("Basic")); + assert_eq!(result.reply, "All set."); +} diff --git a/rust/smooth-operator/src/choices.rs b/rust/smooth-operator/src/choices.rs new file mode 100644 index 00000000..810ddd75 --- /dev/null +++ b/rust/smooth-operator/src/choices.rs @@ -0,0 +1,650 @@ +//! Choices — a structured multiple-choice ask (modeled on Claude Code's +//! `AskUserQuestion`): the second **Rich Interaction kind** (see +//! `docs/Architecture/Rich Interactions.md` and [`crate::interaction`]). +//! +//! The agent asks 1–4 short questions, each with 2–4 labeled options; the turn +//! parks until the visitor picks. Every question also carries an implicit +//! free-text **"Other"** escape hatch, so the visitor can answer outside the +//! enumerated options (exactly as `AskUserQuestion` always offers "Other"). +//! +//! - On a channel that declared the `choice_chips` capability, the agent's +//! `request_choices` tool parks the turn and the server emits +//! `interaction_required { kind: "choices" }`; the client's chip/menu card +//! resumes with a `submit_interaction` action. +//! - On a **text-only** channel the same raise degrades to a conversational +//! directive that enumerates the questions + options ("choices = enumerated +//! ask") and the model submits the picks through the generic +//! `submit_interaction` *tool*. +//! +//! Both paths validate through [`validate_choices`] — one implementation, one +//! behavior — and resume the turn with the same structured payload. +//! [`ChoicesKind`] packages it all as an [`InteractionKind`](crate::interaction::InteractionKind). + +use anyhow::anyhow; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use smooth_operator_core::tool::ToolSchema; + +use crate::interaction::{InteractionFieldError, InteractionKind, InteractionRequest}; + +/// Max length of a question's short `header` label (chip/tab caption). +pub const HEADER_MAX_CHARS: usize = 12; + +/// One selectable option in a question. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChoiceOption { + /// The option's label — the value the visitor submits. + pub label: String, + /// A short human-readable gloss shown under/next to the label. + #[serde(default)] + pub description: String, +} + +/// One question in a [`choices`](ChoicesKind) raise. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChoiceQuestion { + /// The question prompt shown to the visitor. + pub question: String, + /// A short label (≤[`HEADER_MAX_CHARS`] chars) — the answer key and the + /// chip/tab caption. Must be unique within a raise. + pub header: String, + /// The 2–4 enumerated options. An implicit free-text "Other" is always + /// available in addition to these. + pub options: Vec, + /// Whether the visitor may pick more than one option (default `false`). + #[serde(default, rename = "multiSelect")] + pub multi_select: bool, +} + +/// The visitor's answer to one question, submitted via `submit_interaction`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChoiceAnswer { + /// Which question this answers — matches the spec question's `header`. + pub header: String, + /// The selected option label(s). One for single-select; the empty vec when + /// the visitor only used the free-text "Other" escape hatch. + #[serde(default)] + pub options: Vec, + /// The free-text "Other" answer, when the visitor answered outside the + /// enumerated options. Blank ⇒ omitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub other: Option, +} + +impl ChoiceAnswer { + /// Total picks the visitor made (selected labels + one for a non-blank + /// "Other"). + fn selection_count(&self) -> usize { + self.options.len() + usize::from(self.other.is_some()) + } +} + +/// Validated, normalized choice answers — the structured payload the parked +/// turn resumes with (identical on the card and conversational paths). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChoiceValues { + /// One entry per answered question (in submission order). + #[serde(default)] + pub answers: Vec, +} + +/// A single per-question validation failure. `field` is the question's `header`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChoiceFieldError { + /// The question `header` that failed. + pub field: String, + /// Human-readable validation message. + pub message: String, +} + +/// Validate submitted `values` against the raised `questions`, returning the +/// normalized [`ChoiceValues`] or the full list of per-question errors. +/// +/// Rules (see the design doc): +/// - **every** question must be answered (a selection or a non-blank "Other"); +/// - each selected label must be one of that question's option labels; +/// - single-select (`multiSelect: false`): exactly one pick (one label XOR +/// "Other"); multi-select: one or more picks (labels and/or "Other"); +/// - a blank/whitespace "Other" is treated as absent; labels are trimmed. +/// +/// When `questions` is empty (a prior-turn fallback raise whose spec is gone), +/// validation degrades to **format-only**: labels can't be checked for +/// membership, so any answer with at least one pick is accepted as-is. +/// +/// # Errors +/// Returns every failed question (not just the first) so a card can annotate +/// all of them in one round-trip. +pub fn validate_choices( + questions: &[ChoiceQuestion], + values: &ChoiceValues, +) -> Result> { + // Normalize the raw answers first (trim labels + "Other", drop blanks). + let normalized: Vec = values + .answers + .iter() + .map(|a| ChoiceAnswer { + header: a.header.trim().to_string(), + options: a + .options + .iter() + .map(|o| o.trim().to_string()) + .filter(|o| !o.is_empty()) + .collect(), + other: a + .other + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string), + }) + .collect(); + + // Format-only path: no spec to check membership/required-ness against. + if questions.is_empty() { + let mut errors = Vec::new(); + for answer in &normalized { + if answer.selection_count() == 0 { + errors.push(ChoiceFieldError { + field: answer.header.clone(), + message: "select an option or provide an 'other' answer".to_string(), + }); + } + } + if normalized.is_empty() { + errors.push(ChoiceFieldError { + field: "answers".to_string(), + message: "provide an answer for each question, or declined=true".to_string(), + }); + } + return if errors.is_empty() { + Ok(ChoiceValues { + answers: normalized, + }) + } else { + Err(errors) + }; + } + + let mut errors = Vec::new(); + let mut out = Vec::with_capacity(questions.len()); + + for question in questions { + let Some(answer) = normalized.iter().find(|a| a.header == question.header) else { + errors.push(ChoiceFieldError { + field: question.header.clone(), + message: "this question must be answered".to_string(), + }); + continue; + }; + + // Every selected label must be one of the enumerated options. + let mut bad_label = false; + for label in &answer.options { + if !question.options.iter().any(|o| &o.label == label) { + bad_label = true; + errors.push(ChoiceFieldError { + field: question.header.clone(), + message: format!("'{label}' is not one of the offered options"), + }); + } + } + + let count = answer.selection_count(); + if count == 0 { + errors.push(ChoiceFieldError { + field: question.header.clone(), + message: "select an option or provide an 'other' answer".to_string(), + }); + } else if !question.multi_select && count > 1 { + errors.push(ChoiceFieldError { + field: question.header.clone(), + message: "this question takes a single answer".to_string(), + }); + } + + if !bad_label { + out.push(answer.clone()); + } + } + + if errors.is_empty() { + Ok(ChoiceValues { answers: out }) + } else { + Err(errors) + } +} + +/// Parse the raise tool's `questions` argument into validated [`ChoiceQuestion`]s. +/// +/// Enforces the LLM-facing contract so the model produces usable cards: 1–4 +/// questions, each with a non-empty prompt, a non-empty header ≤12 chars +/// (unique within the raise), and 2–4 options with non-empty labels. +fn parse_questions(raw: &Value) -> anyhow::Result> { + let items = raw + .as_array() + .ok_or_else(|| anyhow!("'questions' must be an array"))?; + if !(1..=4).contains(&items.len()) { + return Err(anyhow!( + "'questions' must contain between 1 and 4 questions" + )); + } + let mut questions = Vec::with_capacity(items.len()); + let mut seen_headers = Vec::with_capacity(items.len()); + for item in items { + let obj = item + .as_object() + .ok_or_else(|| anyhow!("each question must be an object"))?; + let question = obj + .get("question") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow!("each question needs a non-empty 'question'"))? + .to_string(); + let header = obj + .get("header") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow!("each question needs a non-empty 'header'"))? + .to_string(); + if header.chars().count() > HEADER_MAX_CHARS { + return Err(anyhow!( + "header '{header}' is too long (max {HEADER_MAX_CHARS} characters)" + )); + } + if seen_headers.contains(&header) { + return Err(anyhow!("duplicate question header '{header}'")); + } + seen_headers.push(header.clone()); + + let raw_options = obj + .get("options") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("question '{header}' needs an 'options' array"))?; + if !(2..=4).contains(&raw_options.len()) { + return Err(anyhow!( + "question '{header}' must offer between 2 and 4 options" + )); + } + let mut options = Vec::with_capacity(raw_options.len()); + for opt in raw_options { + // Accept the object form `{ label, description? }` and the shorthand + // a bare string the model sometimes emits. + let option = match opt { + Value::String(s) => ChoiceOption { + label: s.trim().to_string(), + description: String::new(), + }, + Value::Object(o) => ChoiceOption { + label: o + .get("label") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or_default() + .to_string(), + description: o + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .trim() + .to_string(), + }, + other => return Err(anyhow!("invalid option entry in '{header}': {other}")), + }; + if option.label.is_empty() { + return Err(anyhow!("an option in '{header}' has an empty label")); + } + options.push(option); + } + + questions.push(ChoiceQuestion { + question, + header, + options, + multi_select: obj + .get("multiSelect") + .and_then(Value::as_bool) + .unwrap_or(false), + }); + } + Ok(questions) +} + +/// The `choices` Rich Interaction kind — a structured multiple-choice ask +/// modeled on `AskUserQuestion` (see the module docs and +/// `spec/interactions/choices.schema.json`). +pub struct ChoicesKind; + +impl InteractionKind for ChoicesKind { + fn kind(&self) -> &'static str { + "choices" + } + + fn capability(&self) -> &'static str { + "choice_chips" + } + + fn tool_schema(&self) -> ToolSchema { + ToolSchema { + name: "request_choices".to_string(), + description: "Ask the visitor a structured multiple-choice question (1–4 questions, \ + each with 2–4 labeled options) and wait for their pick. On channels \ + that can render chips/menus the visitor taps an option; on text \ + channels you will be told to enumerate the options and accept a \ + natural-language answer. An implicit free-text \"Other\" is always \ + available, so use this whenever the answer is likely (but not \ + certainly) one of a small set — never free-form the menu yourself." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "questions": { + "type": "array", + "minItems": 1, + "maxItems": 4, + "description": "The questions to ask, in order (1–4).", + "items": { + "type": "object", + "properties": { + "question": { "type": "string", "description": "The question prompt shown to the visitor." }, + "header": { "type": "string", "maxLength": HEADER_MAX_CHARS, "description": "A short label (≤12 chars), unique within the raise. Used as the answer key and the chip/tab caption." }, + "options": { + "type": "array", + "minItems": 2, + "maxItems": 4, + "description": "The 2–4 options to offer. A free-text 'Other' is always available in addition.", + "items": { + "type": "object", + "properties": { + "label": { "type": "string", "description": "The option label (the value submitted)." }, + "description": { "type": "string", "description": "A short gloss for the option." } + }, + "required": ["label"] + } + }, + "multiSelect": { "type": "boolean", "description": "Allow selecting more than one option (default false)." } + }, + "required": ["question", "header", "options"] + } + }, + "reason": { + "type": "string", + "description": "Why you're asking, phrased for the visitor (e.g. \"to route you to the right team\")." + } + }, + "required": ["questions", "reason"] + }), + } + } + + fn parse_request(&self, args: &Value) -> anyhow::Result { + let questions = parse_questions(args.get("questions").unwrap_or(&Value::Null))?; + let reason = args + .get("reason") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("to help you better") + .to_string(); + Ok(InteractionRequest { + kind: self.kind().to_string(), + spec: json!({ "questions": questions }), + reason, + }) + } + + fn validate(&self, spec: &Value, values: &Value) -> Result> { + let questions: Vec = spec + .get("questions") + .cloned() + .and_then(|q| serde_json::from_value(q).ok()) + .unwrap_or_default(); + let values: ChoiceValues = serde_json::from_value(values.clone()).map_err(|e| { + vec![InteractionFieldError { + field: "values".to_string(), + message: format!("invalid values shape: {e}"), + }] + })?; + match validate_choices(&questions, &values) { + Ok(validated) => Ok(serde_json::to_value(validated).unwrap_or(Value::Null)), + Err(errors) => Err(errors + .into_iter() + .map(|e| InteractionFieldError { + field: e.field, + message: e.message, + }) + .collect()), + } + } + + fn fallback_directive(&self, spec: &Value, reason: &str) -> String { + let enumerated = spec + .get("questions") + .and_then(Value::as_array) + .map(|questions| { + questions + .iter() + .filter_map(|q| { + let question = q.get("question").and_then(Value::as_str)?; + let header = q.get("header").and_then(Value::as_str).unwrap_or(question); + let multi = q + .get("multiSelect") + .and_then(Value::as_bool) + .unwrap_or(false); + let opts = q + .get("options") + .and_then(Value::as_array) + .map(|os| { + os.iter() + .filter_map(|o| o.get("label").and_then(Value::as_str)) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + Some(format!( + "- [{header}] {question} Options: {opts}{}.", + if multi { " (choose one or more)" } else { "" } + )) + }) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + format!( + "This visitor's channel cannot display choice chips. Ask the following question(s) \ + conversationally, naturally weaving in the reason ({reason}), and read out each \ + option so the visitor can pick:\n{enumerated}\nThe visitor may also answer with \ + something not listed (that's fine — capture it as their 'other' answer). When you \ + have their pick(s), call the `submit_interaction` tool with kind \"choices\" and \ + `values.answers` — one entry per question `{{ header, options: [chosen label(s)], \ + other?: \"their free-text answer\" }}`. It validates each answer and will tell you \ + if a pick isn't offered so you can re-ask. If the visitor declines to choose, call \ + `submit_interaction` with declined=true and continue helping them." + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opt(label: &str) -> ChoiceOption { + ChoiceOption { + label: label.to_string(), + description: String::new(), + } + } + + fn question(header: &str, labels: &[&str], multi: bool) -> ChoiceQuestion { + ChoiceQuestion { + question: format!("{header}?"), + header: header.to_string(), + options: labels.iter().map(|l| opt(l)).collect(), + multi_select: multi, + } + } + + fn answer(header: &str, options: &[&str], other: Option<&str>) -> ChoiceAnswer { + ChoiceAnswer { + header: header.to_string(), + options: options.iter().map(|s| (*s).to_string()).collect(), + other: other.map(str::to_string), + } + } + + #[test] + fn valid_single_select_normalizes() { + let qs = [question("Plan", &["Basic", "Pro"], false)]; + let values = ChoiceValues { + answers: vec![answer("Plan", &[" Pro "], None)], + }; + let out = validate_choices(&qs, &values).expect("valid"); + assert_eq!(out.answers.len(), 1); + assert_eq!(out.answers[0].options, vec!["Pro".to_string()]); + assert!(out.answers[0].other.is_none()); + } + + #[test] + fn valid_multi_select_keeps_all_picks() { + let qs = [question("Topics", &["Sales", "Support", "Billing"], true)]; + let values = ChoiceValues { + answers: vec![answer("Topics", &["Sales", "Billing"], None)], + }; + let out = validate_choices(&qs, &values).expect("valid"); + assert_eq!(out.answers[0].options, vec!["Sales", "Billing"]); + } + + #[test] + fn other_escape_hatch_is_accepted() { + let qs = [question("Plan", &["Basic", "Pro"], false)]; + let values = ChoiceValues { + answers: vec![answer("Plan", &[], Some(" Enterprise, actually "))], + }; + let out = validate_choices(&qs, &values).expect("valid"); + assert!(out.answers[0].options.is_empty()); + assert_eq!( + out.answers[0].other.as_deref(), + Some("Enterprise, actually") + ); + } + + #[test] + fn unknown_label_is_a_field_error() { + let qs = [question("Plan", &["Basic", "Pro"], false)]; + let values = ChoiceValues { + answers: vec![answer("Plan", &["Platinum"], None)], + }; + let err = validate_choices(&qs, &values).unwrap_err(); + assert_eq!(err.len(), 1); + assert_eq!(err[0].field, "Plan"); + assert!(err[0].message.contains("not one of the offered")); + } + + #[test] + fn single_select_rejects_multiple_picks() { + let qs = [question("Plan", &["Basic", "Pro"], false)]; + let values = ChoiceValues { + answers: vec![answer("Plan", &["Basic", "Pro"], None)], + }; + let err = validate_choices(&qs, &values).unwrap_err(); + assert!(err.iter().any(|e| e.message.contains("single answer"))); + } + + #[test] + fn unanswered_question_is_required() { + let qs = [ + question("Plan", &["Basic", "Pro"], false), + question("Size", &["S", "M"], false), + ]; + let values = ChoiceValues { + answers: vec![answer("Plan", &["Pro"], None)], + }; + let err = validate_choices(&qs, &values).unwrap_err(); + assert_eq!(err.len(), 1); + assert_eq!(err[0].field, "Size"); + assert!(err[0].message.contains("must be answered")); + } + + #[test] + fn empty_answer_needs_a_pick_or_other() { + let qs = [question("Plan", &["Basic", "Pro"], false)]; + let values = ChoiceValues { + answers: vec![answer("Plan", &[], None)], + }; + let err = validate_choices(&qs, &values).unwrap_err(); + assert!(err.iter().any(|e| e.message.contains("select an option"))); + } + + #[test] + fn parse_questions_enforces_the_contract() { + // Happy path with shorthand string options. + let qs = parse_questions(&json!([ + { "question": "Which plan?", "header": "Plan", "options": ["Basic", "Pro"] } + ])) + .expect("valid"); + assert_eq!(qs.len(), 1); + assert_eq!(qs[0].options[0].label, "Basic"); + assert!(!qs[0].multi_select); + + // Too many questions. + let too_many: Vec = (0..5) + .map(|i| json!({ "question": "q", "header": format!("H{i}"), "options": ["a", "b"] })) + .collect(); + assert!(parse_questions(&json!(too_many)).is_err()); + + // Too few options. + assert!(parse_questions(&json!([ + { "question": "q", "header": "H", "options": ["only"] } + ])) + .is_err()); + + // Header too long. + assert!(parse_questions(&json!([ + { "question": "q", "header": "ThisHeaderIsWayTooLong", "options": ["a", "b"] } + ])) + .is_err()); + + // Duplicate headers. + assert!(parse_questions(&json!([ + { "question": "q1", "header": "H", "options": ["a", "b"] }, + { "question": "q2", "header": "H", "options": ["a", "b"] } + ])) + .is_err()); + } + + #[test] + fn kind_wires_the_reference_surface() { + let kind = ChoicesKind; + assert_eq!(kind.kind(), "choices"); + assert_eq!(kind.capability(), "choice_chips"); + assert_eq!(kind.tool_schema().name, "request_choices"); + + let req = kind + .parse_request(&json!({ + "questions": [ + { "question": "Which plan interests you?", "header": "Plan", + "options": [{ "label": "Basic" }, { "label": "Pro" }] } + ], + "reason": "to route you" + })) + .expect("parse"); + assert_eq!(req.kind, "choices"); + assert_eq!(req.reason, "to route you"); + assert_eq!(req.spec["questions"][0]["header"], "Plan"); + + // The validator, through the trait, produces the canonical values. + let canonical = kind + .validate( + &req.spec, + &json!({ "answers": [{ "header": "Plan", "options": ["Pro"] }] }), + ) + .expect("valid"); + assert_eq!(canonical["answers"][0]["options"][0], "Pro"); + + // The fallback directive enumerates the options. + let directive = kind.fallback_directive(&req.spec, "to route you"); + assert!(directive.contains("Basic, Pro")); + assert!(directive.contains("submit_interaction")); + } +} diff --git a/rust/smooth-operator/src/interaction.rs b/rust/smooth-operator/src/interaction.rs index 1a048acb..5a87a274 100644 --- a/rust/smooth-operator/src/interaction.rs +++ b/rust/smooth-operator/src/interaction.rs @@ -108,8 +108,9 @@ pub trait InteractionKind: Send + Sync { } /// The set of interaction kinds a server hosts. The default registry contains -/// the reference kinds ([`IdentityIntakeKind`](crate::identity_intake::IdentityIntakeKind)); -/// a host may extend or replace it. +/// the reference kinds ([`IdentityIntakeKind`](crate::identity_intake::IdentityIntakeKind) +/// and [`ChoicesKind`](crate::choices::ChoicesKind)); a host may extend or +/// replace it. #[derive(Clone)] pub struct InteractionRegistry { kinds: Vec>, @@ -144,9 +145,11 @@ impl InteractionRegistry { } impl Default for InteractionRegistry { - /// The reference catalog: `identity_intake`. + /// The reference catalog: `identity_intake` and `choices`. fn default() -> Self { - Self::empty().with(Arc::new(crate::identity_intake::IdentityIntakeKind)) + Self::empty() + .with(Arc::new(crate::identity_intake::IdentityIntakeKind)) + .with(Arc::new(crate::choices::ChoicesKind)) } } @@ -162,6 +165,11 @@ mod tests { .expect("identity_intake registered"); assert_eq!(kind.capability(), "identity_form"); assert_eq!(kind.tool_schema().name, "request_identity_intake"); + + let choices = reg.get("choices").expect("choices registered"); + assert_eq!(choices.capability(), "choice_chips"); + assert_eq!(choices.tool_schema().name, "request_choices"); + assert!(reg.get("date_picker").is_none(), "unknown kinds are None"); } } diff --git a/rust/smooth-operator/src/lib.rs b/rust/smooth-operator/src/lib.rs index 8fa88248..0719c4c2 100644 --- a/rust/smooth-operator/src/lib.rs +++ b/rust/smooth-operator/src/lib.rs @@ -24,6 +24,7 @@ pub mod adapter; pub mod agent_config; pub mod auth; pub mod backplane; +pub mod choices; pub mod connector_config; pub mod curation; pub mod domain; @@ -53,6 +54,10 @@ pub use auth::{ AuthConfig, AuthError, AuthVerifier, JwtVerifier, LocalTokenVerifier, NoAuthVerifier, Principal, Role, SmooIdentityVerifier, }; +pub use choices::{ + validate_choices, ChoiceAnswer, ChoiceFieldError, ChoiceOption, ChoiceQuestion, ChoiceValues, + ChoicesKind, +}; pub use connector_config::{ ConnectorConfig, ConnectorConfigStore, ConnectorKind, InMemoryConnectorConfigStore, }; diff --git a/spec/actions/create-conversation-session.schema.json b/spec/actions/create-conversation-session.schema.json index 1a6dd15a..46dccc87 100644 --- a/spec/actions/create-conversation-session.schema.json +++ b/spec/actions/create-conversation-session.schema.json @@ -42,7 +42,7 @@ "supports": { "type": "array", "items": { "type": "string" }, - "description": "Client render capabilities for this session \u2014 a per-kind list gating the Rich Interactions the server may emit mid-turn (`interaction_required`). Each interaction kind declares the capability that gates it (e.g. kind `identity_intake` \u2192 capability `identity_form`); future kinds add their own values (`date_picker`, `file_upload`, \u2026). Text-only channels (SMS, voice) omit this and the server degrades each kind to its conversational fallback. Unknown values are ignored (forward-compatible)." + "description": "Client render capabilities for this session \u2014 a per-kind list gating the Rich Interactions the server may emit mid-turn (`interaction_required`). Each interaction kind declares the capability that gates it (e.g. kind `identity_intake` \u2192 capability `identity_form`, kind `choices` \u2192 capability `choice_chips`); future kinds add their own values (`date_picker`, `file_upload`, \u2026). Text-only channels (SMS, voice) omit this and the server degrades each kind to its conversational fallback. Unknown values are ignored (forward-compatible)." }, "metadata": { "type": "object", diff --git a/spec/conformance/fixtures.json b/spec/conformance/fixtures.json index bb8e0d6b..dacb0ce2 100644 --- a/spec/conformance/fixtures.json +++ b/spec/conformance/fixtures.json @@ -304,6 +304,55 @@ } } }, + "choices_spec": { + "$schema_ref": "interactions/choices.schema.json#/$defs/Spec", + "description": "The choices kind's spec, as carried in interaction_required.data.data.spec.", + "instance": { + "questions": [ + { + "question": "Which plan are you interested in?", + "header": "Plan", + "options": [ + { "label": "Basic", "description": "For individuals" }, + { "label": "Pro", "description": "For growing teams" } + ] + }, + { + "question": "What topics can we help with?", + "header": "Topics", + "options": [ + { "label": "Sales" }, + { "label": "Support" }, + { "label": "Billing" } + ], + "multiSelect": true + } + ] + } + }, + "choices_values": { + "$schema_ref": "interactions/choices.schema.json#/$defs/Values", + "description": "The choices kind's submit values, as carried in submit_interaction.values.", + "instance": { + "answers": [ + { "header": "Plan", "options": ["Pro"] }, + { "header": "Topics", "options": ["Sales", "Billing"], "other": "Partnerships" } + ] + } + }, + "choices_payload": { + "$schema_ref": "interactions/choices.schema.json#/$defs/Payload", + "description": "The canonical validated choices payload the parked turn resumes with.", + "instance": { + "status": "submitted", + "values": { + "answers": [ + { "header": "Plan", "options": ["Pro"] }, + { "header": "Topics", "options": ["Sales", "Billing"], "other": "Partnerships" } + ] + } + } + }, "submit_interaction_declined_request": { "$schema_ref": "actions/submit-interaction.schema.json#/$defs/Request", "description": "Resume a parked interaction with a decline; the agent handles it gracefully.", diff --git a/spec/interactions/choices.schema.json b/spec/interactions/choices.schema.json new file mode 100644 index 00000000..2004e4c1 --- /dev/null +++ b/spec/interactions/choices.schema.json @@ -0,0 +1,146 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://smooth-agent.dev/spec/interactions/choices.schema.json", + "title": "ChoicesInteraction", + "description": "The `choices` interaction kind — a structured multiple-choice ask modeled on Claude Code's AskUserQuestion: 1–4 short questions, each with 2–4 labeled options, that the turn parks on until the visitor picks. Render capability: `choice_chips`. On capable channels the client renders chips/menus from `Spec`; on text-only channels the server degrades to an enumerated conversational ask. Every question also carries an implicit free-text `other` escape hatch, so the visitor can always answer outside the enumerated options. Either way the turn resumes with the same validated `Payload`. Kind-specific shapes referenced by the generic `interaction_required` / `submit_interaction` envelope.", + + "$defs": { + "Spec": { + "title": "ChoicesSpec", + "description": "The `spec` carried on `interaction_required` for kind `choices`: the questions to ask.", + "type": "object", + "required": ["questions"], + "additionalProperties": false, + "properties": { + "questions": { + "type": "array", + "minItems": 1, + "maxItems": 4, + "description": "The questions to ask, in display order (1–4).", + "items": { + "type": "object", + "required": ["question", "header", "options"], + "additionalProperties": false, + "properties": { + "question": { + "type": "string", + "description": "The question prompt shown to the visitor." + }, + "header": { + "type": "string", + "maxLength": 12, + "description": "A short label (≤12 chars), unique within the raise. The answer key and the chip/tab caption." + }, + "options": { + "type": "array", + "minItems": 2, + "maxItems": 4, + "description": "The enumerated options. A free-text `other` answer is always available in addition to these.", + "items": { + "type": "object", + "required": ["label"], + "additionalProperties": false, + "properties": { + "label": { + "type": "string", + "description": "The option label — the value the visitor submits." + }, + "description": { + "type": "string", + "description": "A short human-readable gloss for the option." + } + } + } + }, + "multiSelect": { + "type": "boolean", + "default": false, + "description": "Whether the visitor may select more than one option (default false)." + } + } + } + } + } + }, + + "Values": { + "title": "ChoicesValues", + "description": "The `values` a client submits via `submit_interaction` for kind `choices`. Validated server-side: every question answered, each selected label is one of that question's options, single-select takes exactly one pick. The free-text `other` is always accepted (the AskUserQuestion 'Other' escape hatch).", + "type": "object", + "required": ["answers"], + "additionalProperties": false, + "properties": { + "answers": { + "type": "array", + "description": "One entry per question, keyed by the question's `header`.", + "items": { + "type": "object", + "required": ["header"], + "additionalProperties": false, + "properties": { + "header": { + "type": "string", + "description": "Which question this answers — matches the spec question's `header`." + }, + "options": { + "type": "array", + "description": "The selected option label(s). One for single-select; empty when the visitor only used `other`.", + "items": { "type": "string" } + }, + "other": { + "type": "string", + "description": "A free-text answer outside the enumerated options (the 'Other' escape hatch). Blank ⇒ omitted." + } + } + } + } + } + }, + + "Payload": { + "title": "ChoicesPayload", + "description": "The canonical validated payload the parked turn resumes with (identical on the chip and conversational paths).", + "type": "object", + "required": ["status"], + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": ["submitted", "declined", "no_response"], + "description": "How the interaction resolved." + }, + "values": { + "type": "object", + "description": "Present when `status` is `submitted`: the validated, normalized answers.", + "required": ["answers"], + "additionalProperties": false, + "properties": { + "answers": { + "type": "array", + "items": { + "type": "object", + "required": ["header"], + "additionalProperties": false, + "properties": { + "header": { "type": "string" }, + "options": { "type": "array", "items": { "type": "string" } }, + "other": { "type": "string" } + } + } + } + } + }, + "message": { + "type": "string", + "description": "Guidance for the agent when `status` is `declined` / `no_response`." + } + } + } + }, + + "oneOf": [ + { "$ref": "#/$defs/Spec" }, + { "$ref": "#/$defs/Values" }, + { "$ref": "#/$defs/Payload" } + ] +}