@lotics/app-sdk 0.54.2 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -20,7 +20,7 @@ signature; open the file.**
20
20
  | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs, workflow-generated files, preview pairing, filter operators, the server-side delivery bounds. |
21
21
  | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `useViewer`, `useComments`, and the `@lotics/ui` components they feed. |
22
22
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
23
- | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics, auto query-refetch on chat mutation). |
23
+ | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics, auto query-refetch on chat mutation). |
24
24
  | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, what runtime refinement cannot widen. |
25
25
  | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, `openExternal`/`downloadFile`, geofencing, analytics, `useConfig` (App-Packages installation config), `getAppBinding` (package apps' runtime `F`/`OPT`/`ROLE` resolution via the generated `.lotics/app_fields.ts`), and the publish chain for package contributors. |
26
26
 
@@ -19,8 +19,15 @@
19
19
  import type { UIMessagePart, UIDataTypes, UITools } from "ai";
20
20
  /** An ai-sdk message part — the render model, tool-set-agnostic. */
21
21
  export type AgentUIPart = UIMessagePart<UIDataTypes, UITools>;
22
+ /**
23
+ * The MANDATORY interactive tools every app-agent run carries (mirrors the
24
+ * server's `INTERACTIVE_APP_TOOLS`). A call to one PARKS the run awaiting the
25
+ * user's answer: its part stays `input-available` through `finish`, the state
26
+ * reads `awaiting_input`, and `useAgentRun().answerChoice` continues the run.
27
+ */
28
+ export declare const INTERACTIVE_TOOLS: ReadonlySet<string>;
22
29
  export interface AgentRunState {
23
- status: "streaming" | "completed" | "error";
30
+ status: "streaming" | "awaiting_input" | "completed" | "error";
24
31
  /** The ordered transcript as ai-sdk `parts` — prose interleaved with the tool
25
32
  * calls (`dynamic-tool` parts), as it streamed. The single source of truth for
26
33
  * the feed; `useAgentRun` derives `text` / `steps` from it. */
@@ -51,6 +58,61 @@ export interface SettledAgentRun {
51
58
  * would crash it). The free-text answer stays in the transcript text.
52
59
  */
53
60
  export declare function adoptSettledRun(state: AgentRunState, settled: SettledAgentRun): AgentRunState;
61
+ export type ChoiceOption = {
62
+ label: string;
63
+ description: string;
64
+ };
65
+ export type ChoiceQuestion = {
66
+ question: string;
67
+ options: ChoiceOption[];
68
+ allow_custom?: boolean;
69
+ };
70
+ export type AskUserChoiceAnswer = {
71
+ type: "option";
72
+ question_index: number;
73
+ question_number: number;
74
+ question_text: string;
75
+ option_index: number;
76
+ option_letter: string;
77
+ option_label: string;
78
+ option_description: string;
79
+ } | {
80
+ type: "custom";
81
+ question_index: number;
82
+ question_number: number;
83
+ question_text: string;
84
+ text: string;
85
+ } | {
86
+ type: "skipped";
87
+ question_index: number;
88
+ question_number: number;
89
+ question_text: string;
90
+ };
91
+ export type AskUserChoiceOutput = {
92
+ answers: AskUserChoiceAnswer[];
93
+ skipped_by_user?: boolean;
94
+ };
95
+ export interface PendingChoice {
96
+ toolCallId: string;
97
+ questions: ChoiceQuestion[];
98
+ }
99
+ /** The run's pending `ask_user_choice`, parsed for rendering (a `ClarifyWizard`
100
+ * maps 1:1) — non-null exactly while the run is parked awaiting the answer. */
101
+ export declare function pendingInteractiveCall(state: AgentRunState): PendingChoice | null;
102
+ /**
103
+ * Fold the user's picks into the tool's output shape — `answers` aligns to
104
+ * `questions` by index (the `ClarifyWizard` contract: `{ value, custom }` per
105
+ * question, value = the picked option's label or the free text). A missing or
106
+ * empty entry is a skipped question.
107
+ */
108
+ export declare function buildChoiceOutput(questions: ChoiceQuestion[], answers: {
109
+ value: string;
110
+ custom: boolean;
111
+ }[]): AskUserChoiceOutput;
112
+ /** The user answered: settle the pending part (the answer rides its `output` for
113
+ * the feed's on-demand reveal) and put the run back into `streaming` for the
114
+ * continuation leg's chunks. */
115
+ export declare function applyInteractiveAnswer(state: AgentRunState, toolCallId: string, output: AskUserChoiceOutput): AgentRunState;
54
116
  export declare function initialAgentRunState(): AgentRunState;
55
117
  /** A parsed UI-message chunk — only the fields we read, all optional. */
56
118
  interface Chunk {
@@ -16,6 +16,13 @@
16
16
  * free-text (see `AgentRunState.output`). Pure + reducer-shaped so it is fully
17
17
  * unit-testable against recorded frames — no live model needed.
18
18
  */
19
+ /**
20
+ * The MANDATORY interactive tools every app-agent run carries (mirrors the
21
+ * server's `INTERACTIVE_APP_TOOLS`). A call to one PARKS the run awaiting the
22
+ * user's answer: its part stays `input-available` through `finish`, the state
23
+ * reads `awaiting_input`, and `useAgentRun().answerChoice` continues the run.
24
+ */
25
+ export const INTERACTIVE_TOOLS = new Set(["ask_user_choice"]);
19
26
  /** The tool the backend injects to carry a typed structured result. */
20
27
  const SUBMIT_TOOL = "submit_result";
21
28
  /**
@@ -34,8 +41,96 @@ export function adoptSettledRun(state, settled) {
34
41
  const structured = settled.output !== null && typeof settled.output === "object" ? settled.output : undefined;
35
42
  return { ...state, status: "completed", output: structured ?? state.output };
36
43
  }
44
+ // A PARKED row is a live, resumable state — the pending ask is already in the
45
+ // transcript parts; never coerce it into an error.
46
+ if (settled.status === "awaiting_input") {
47
+ return { ...state, status: "awaiting_input" };
48
+ }
37
49
  return { ...state, status: "error", error: settled.error_message ?? "The run was stopped." };
38
50
  }
51
+ /** The run's pending `ask_user_choice`, parsed for rendering (a `ClarifyWizard`
52
+ * maps 1:1) — non-null exactly while the run is parked awaiting the answer. */
53
+ export function pendingInteractiveCall(state) {
54
+ if (state.status !== "awaiting_input")
55
+ return null;
56
+ for (let i = state.parts.length - 1; i >= 0; i--) {
57
+ const p = state.parts[i];
58
+ if (p.type !== "dynamic-tool" || !INTERACTIVE_TOOLS.has(p.toolName))
59
+ continue;
60
+ if (p.state !== "input-available" && p.state !== "input-streaming")
61
+ continue;
62
+ const questions = parseChoiceQuestions(p.input);
63
+ return questions.length > 0 ? { toolCallId: p.toolCallId, questions } : null;
64
+ }
65
+ return null;
66
+ }
67
+ function parseChoiceQuestions(input) {
68
+ if (!input || typeof input !== "object")
69
+ return [];
70
+ const record = input;
71
+ if (!Array.isArray(record.questions))
72
+ return [];
73
+ const questions = [];
74
+ for (const raw of record.questions) {
75
+ if (!raw || typeof raw !== "object")
76
+ continue;
77
+ const q = raw;
78
+ if (typeof q.question !== "string" || !Array.isArray(q.options))
79
+ continue;
80
+ const options = [];
81
+ for (const o of q.options) {
82
+ if (o && typeof o === "object" && typeof o.label === "string") {
83
+ const opt = o;
84
+ options.push({ label: opt.label, description: typeof opt.description === "string" ? opt.description : "" });
85
+ }
86
+ }
87
+ questions.push({ question: q.question, options, allow_custom: q.allow_custom === true });
88
+ }
89
+ return questions;
90
+ }
91
+ /**
92
+ * Fold the user's picks into the tool's output shape — `answers` aligns to
93
+ * `questions` by index (the `ClarifyWizard` contract: `{ value, custom }` per
94
+ * question, value = the picked option's label or the free text). A missing or
95
+ * empty entry is a skipped question.
96
+ */
97
+ export function buildChoiceOutput(questions, answers) {
98
+ const toLetter = (i) => String.fromCharCode("A".charCodeAt(0) + i);
99
+ const built = questions.map((question, index) => {
100
+ const questionNumber = index + 1;
101
+ const answer = answers[index];
102
+ const value = answer?.value.trim() ?? "";
103
+ if (!answer || value.length === 0) {
104
+ return { type: "skipped", question_index: index, question_number: questionNumber, question_text: question.question };
105
+ }
106
+ const optionIndex = answer.custom ? -1 : question.options.findIndex((o) => o.label === answer.value);
107
+ if (optionIndex >= 0) {
108
+ const option = question.options[optionIndex];
109
+ return {
110
+ type: "option",
111
+ question_index: index,
112
+ question_number: questionNumber,
113
+ question_text: question.question,
114
+ option_index: optionIndex,
115
+ option_letter: toLetter(optionIndex),
116
+ option_label: option.label,
117
+ option_description: option.description,
118
+ };
119
+ }
120
+ return { type: "custom", question_index: index, question_number: questionNumber, question_text: question.question, text: value };
121
+ });
122
+ const answeredCount = built.filter((a) => a.type !== "skipped").length;
123
+ return { answers: built, skipped_by_user: answeredCount === 0 };
124
+ }
125
+ /** The user answered: settle the pending part (the answer rides its `output` for
126
+ * the feed's on-demand reveal) and put the run back into `streaming` for the
127
+ * continuation leg's chunks. */
128
+ export function applyInteractiveAnswer(state, toolCallId, output) {
129
+ const parts = state.parts.map((p) => p.type === "dynamic-tool" && p.toolCallId === toolCallId
130
+ ? { type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-available", input: p.input, output }
131
+ : p);
132
+ return { ...state, status: "streaming", parts };
133
+ }
39
134
  export function initialAgentRunState() {
40
135
  return { status: "streaming", parts: [] };
41
136
  }
@@ -110,8 +205,14 @@ export function reduceAgentChunk(state, chunk) {
110
205
  // is whatever `submit_result` set (else undefined) — NEVER the accumulated text:
111
206
  // a structured-output consumer reads `output.<field>`, so a stray free-text
112
207
  // string there would crash it. The free-text answer is always in a text part.
208
+ // ONE exemption: a pending INTERACTIVE call (`ask_user_choice`) is the run
209
+ // PARKING, not finishing — its part stays `input-available` and the state
210
+ // reads `awaiting_input` so the app renders the question and continues.
211
+ const awaiting = state.parts.some((p) => p.type === "dynamic-tool" && INTERACTIVE_TOOLS.has(p.toolName) && (p.state === "input-available" || p.state === "input-streaming"));
113
212
  const parts = state.parts.map((p) => {
114
- if (p.type === "dynamic-tool" && (p.state === "input-available" || p.state === "input-streaming")) {
213
+ if (p.type === "dynamic-tool" &&
214
+ (p.state === "input-available" || p.state === "input-streaming") &&
215
+ !INTERACTIVE_TOOLS.has(p.toolName)) {
115
216
  return { type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-available", input: p.input, output: undefined };
116
217
  }
117
218
  if ((p.type === "text" || p.type === "reasoning") && p.state === "streaming") {
@@ -119,7 +220,8 @@ export function reduceAgentChunk(state, chunk) {
119
220
  }
120
221
  return p;
121
222
  });
122
- return { ...state, status: state.status === "error" ? "error" : "completed", parts };
223
+ const status = state.status === "error" ? "error" : awaiting ? "awaiting_input" : "completed";
224
+ return { ...state, status, parts };
123
225
  }
124
226
  default:
125
227
  return state;
@@ -1,9 +1,10 @@
1
1
  import { type AiContextValue } from "./rpc.js";
2
- import { type AgentUIPart } from "./agent_stream.js";
2
+ import { type AgentUIPart, type PendingChoice } from "./agent_stream.js";
3
3
  import type { AppWorkflows, AppWorkflowResults, AppQueries, AppAgents, AppAgentResults } from "./types.js";
4
4
  import type { ResolvedMember } from "./members.js";
5
5
  import type { ResolvedOption } from "./select.js";
6
- export type { AgentRunState, AgentUIPart } from "./agent_stream.js";
6
+ export type { AgentRunState, AgentUIPart, PendingChoice, ChoiceQuestion, ChoiceOption, AskUserChoiceOutput } from "./agent_stream.js";
7
+ export { buildChoiceOutput } from "./agent_stream.js";
7
8
  /** Fields shared by every query hook's return value. */
8
9
  interface QueryStateBase {
9
10
  /**
@@ -469,12 +470,27 @@ export interface UseAgentRun<TInput, TOutput> {
469
470
  /** Explicitly stop the run server-side (saves tokens) AND locally. Wire a
470
471
  * user-facing "Stop" button to this, not `abort`. */
471
472
  cancel: () => void;
472
- status: "idle" | "streaming" | "completed" | "error";
473
+ /** `awaiting_input` = the run is PARKED on a question the agent asked
474
+ * (`pendingChoice` carries it); `answerChoice` continues the run. */
475
+ status: "idle" | "streaming" | "awaiting_input" | "completed" | "error";
473
476
  /** The ordered run transcript as ai-sdk `parts` — prose interleaved with tool
474
477
  * calls, in stream order. Feed straight to `@lotics/ui` `AgentRun`:
475
478
  * `<AgentRun parts={run.parts} state={run.status === "streaming" ? "streaming" : run.status === "error" ? "error" : "done"} />`
476
479
  * — no hand-assembly. */
477
480
  parts: AgentUIPart[];
481
+ /** The agent's pending ask — non-null exactly while `status` is
482
+ * `awaiting_input`. `questions` maps 1:1 onto `@lotics/ui`'s `ClarifyWizard`
483
+ * (`{question, options: {label, description}[], allow_custom}`). */
484
+ pendingChoice: PendingChoice | null;
485
+ /** Answer the pending ask and CONTINUE the run — one `{value, custom}` per
486
+ * question, aligned by index (exactly what `ClarifyWizard`'s `onSubmit`
487
+ * yields). Streams the continuation into the same `parts` and resolves like
488
+ * `run` (the structured output, or `undefined`). Rejects when nothing is
489
+ * pending. */
490
+ answerChoice: (answers: {
491
+ value: string;
492
+ custom: boolean;
493
+ }[]) => Promise<TOutput | undefined>;
478
494
  /** The agent's ANSWER prose (every `text` part concatenated), accumulating live —
479
495
  * excludes thinking (`reasoning` is its own part). For a FREE-TEXT agent this IS
480
496
  * the result; a structured agent's result is `output`. Derived from `parts`. */
package/dist/src/hooks.js CHANGED
@@ -18,10 +18,11 @@
18
18
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19
19
  import useSWR from "swr";
20
20
  import useSWRInfinite from "swr/infinite";
21
- import { rpc, rpcAgentRun, postHostNotification, subscribeHostRefetch, } from "./rpc.js";
22
- import { initialAgentRunState, reduceAgentChunk, parseSseChunks, adoptSettledRun, } from "./agent_stream.js";
21
+ import { rpc, rpcAgentRun, rpcAgentRunContinue, postHostNotification, subscribeHostRefetch, } from "./rpc.js";
22
+ import { initialAgentRunState, reduceAgentChunk, parseSseChunks, adoptSettledRun, pendingInteractiveCall, buildChoiceOutput, applyInteractiveAnswer, } from "./agent_stream.js";
23
23
  import { getMockRows, hasMockFlag } from "./mock.js";
24
24
  import { captureAppEvent } from "./analytics.js";
25
+ export { buildChoiceOutput } from "./agent_stream.js";
25
26
  export function useWorkflow(alias) {
26
27
  return useCallback((inputs) => rpc("workflow", { alias, inputs: inputs ?? {} }), [alias]);
27
28
  }
@@ -451,27 +452,28 @@ export function useAgentRun(alias) {
451
452
  handleRef.current?.abort();
452
453
  };
453
454
  }, []);
455
+ // The latest state, readable synchronously (answerChoice needs the pending
456
+ // call + parts without waiting a render).
457
+ const stateRef = useRef(null);
454
458
  const safeSetState = useCallback((s) => {
459
+ stateRef.current = s;
455
460
  if (mountedRef.current)
456
461
  setState(s);
457
462
  }, []);
458
- const run = useCallback((input, opts) => {
459
- // Single-flight: an extra press must not become a second paid run — the
460
- // old abort-and-restart default kept the first run executing (and
461
- // billing) server-side while the client went blind to it. Joining the
462
- // in-flight promise makes a double-click resolve with the first run's
463
- // result; `replace: true` is the explicit opt-in to abort-and-restart.
464
- if (inflightRef.current && !opts.replace) {
465
- captureAppEvent("app_agent_run_deduped", { alias });
466
- return inflightRef.current;
467
- }
468
- handleRef.current?.abort();
469
- runIdRef.current = null;
470
- let acc = initialAgentRunState();
463
+ /**
464
+ * Drive ONE streaming leg (the initial run, or a continuation after an
465
+ * answered ask) into shared state: fold SSE chunks from `initial`, and on a
466
+ * cut stream fall back to the persisted row the run is decoupled from the
467
+ * connection, so a truncation polls the row instead of surfacing a network
468
+ * error. A leg that ends `awaiting_input` resolves with `undefined`; the
469
+ * final leg resolves with the structured output.
470
+ */
471
+ const streamLeg = useCallback((start, initial) => {
472
+ let acc = initial;
471
473
  let buffer = "";
472
474
  let aborted = false;
473
475
  safeSetState(acc);
474
- const handle = rpcAgentRun({ alias, session_id: opts.sessionId, input: input ?? {} }, (textChunk) => {
476
+ const handle = start((textChunk) => {
475
477
  if (aborted)
476
478
  return;
477
479
  buffer += textChunk;
@@ -482,8 +484,6 @@ export function useAgentRun(alias) {
482
484
  for (const c of chunks)
483
485
  acc = reduceAgentChunk(acc, c);
484
486
  safeSetState({ ...acc });
485
- }, (runId) => {
486
- runIdRef.current = runId;
487
487
  });
488
488
  // Wrap abort so a stop (user or unmount) marks the run cancelled and clears
489
489
  // the partial state back to idle — `done` resolves cleanly, never an error.
@@ -496,7 +496,7 @@ export function useAgentRun(alias) {
496
496
  safeSetState(null);
497
497
  },
498
498
  };
499
- const inflight = handle.done
499
+ return handle.done
500
500
  .then(async () => {
501
501
  if (aborted)
502
502
  return undefined;
@@ -571,13 +571,52 @@ export function useAgentRun(alias) {
571
571
  safeSetState(acc);
572
572
  throw err;
573
573
  });
574
+ }, [safeSetState]);
575
+ const run = useCallback((input, opts) => {
576
+ // Single-flight: an extra press must not become a second paid run — the
577
+ // old abort-and-restart default kept the first run executing (and
578
+ // billing) server-side while the client went blind to it. Joining the
579
+ // in-flight promise makes a double-click resolve with the first run's
580
+ // result; `replace: true` is the explicit opt-in to abort-and-restart.
581
+ if (inflightRef.current && !opts.replace) {
582
+ captureAppEvent("app_agent_run_deduped", { alias });
583
+ return inflightRef.current;
584
+ }
585
+ handleRef.current?.abort();
586
+ runIdRef.current = null;
587
+ const inflight = streamLeg((onText) => rpcAgentRun({ alias, session_id: opts.sessionId, input: input ?? {} }, onText, (runId) => {
588
+ runIdRef.current = runId;
589
+ }), initialAgentRunState());
590
+ const tracked = inflight.finally(() => {
591
+ if (inflightRef.current === tracked)
592
+ inflightRef.current = null;
593
+ });
594
+ inflightRef.current = tracked;
595
+ return tracked;
596
+ }, [alias, safeSetState, streamLeg]);
597
+ // The run's pending ask — non-null exactly while it is parked on a question.
598
+ const pendingChoice = state ? pendingInteractiveCall(state) : null;
599
+ const answerChoice = useCallback((answers) => {
600
+ const current = stateRef.current;
601
+ const runId = runIdRef.current;
602
+ const pending = current ? pendingInteractiveCall(current) : null;
603
+ if (!current || !runId || !pending) {
604
+ return Promise.reject(new Error("No pending question to answer."));
605
+ }
606
+ if (inflightRef.current)
607
+ return inflightRef.current;
608
+ const output = buildChoiceOutput(pending.questions, answers);
609
+ const inflight = streamLeg((onText) => rpcAgentRunContinue({ run_id: runId, tool_call_id: pending.toolCallId, output: output }, onText),
610
+ // The answered part settles locally (its output rides the feed's
611
+ // on-demand reveal) and the state re-enters streaming for the leg.
612
+ applyInteractiveAnswer(current, pending.toolCallId, output));
574
613
  const tracked = inflight.finally(() => {
575
614
  if (inflightRef.current === tracked)
576
615
  inflightRef.current = null;
577
616
  });
578
617
  inflightRef.current = tracked;
579
618
  return tracked;
580
- }, [alias, safeSetState]);
619
+ }, [streamLeg]);
581
620
  const abort = useCallback(() => handleRef.current?.abort(), []);
582
621
  const cancel = useCallback(() => {
583
622
  // Stop server-side too (saves tokens on an unwanted run), then locally.
@@ -596,6 +635,8 @@ export function useAgentRun(alias) {
596
635
  cancel,
597
636
  status: state?.status ?? "idle",
598
637
  parts,
638
+ pendingChoice,
639
+ answerChoice,
599
640
  text,
600
641
  output: state?.output,
601
642
  error: state?.error,
@@ -16,8 +16,8 @@
16
16
  */
17
17
  export { mount } from "./mount.js";
18
18
  export type { MountOptions } from "./mount.js";
19
- export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, } from "./hooks.js";
20
- export type { UploadedFile, AttachedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunRecord, AgentRunState, AgentUIPart, FieldOptions, FieldOptionsState, FieldOptionsOptions, } from "./hooks.js";
19
+ export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
20
+ export type { UploadedFile, AttachedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunRecord, AgentRunState, AgentUIPart, PendingChoice, ChoiceQuestion, ChoiceOption, AskUserChoiceOutput, FieldOptions, FieldOptionsState, FieldOptionsOptions, } from "./hooks.js";
21
21
  export { useComments, useCommentCounts } from "./comments.js";
22
22
  export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
23
23
  export { useViewer } from "./viewer.js";
package/dist/src/index.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * not raw HTML/CSS. See `docs/apps.md` → "Styling & components".
16
16
  */
17
17
  export { mount } from "./mount.js";
18
- export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, } from "./hooks.js";
18
+ export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
19
19
  export { useComments, useCommentCounts } from "./comments.js";
20
20
  export { useViewer } from "./viewer.js";
21
21
  export { useConfig } from "./config.js";
package/dist/src/row.d.ts CHANGED
@@ -74,9 +74,9 @@ export interface AppFile {
74
74
  thumbnail_url?: string;
75
75
  /** Byte size of the file, resolved from the file object at serving time. Absent
76
76
  * for older files not yet backfilled — show a size only when present. Prefer a
77
- * dedicated sortable `Table` column over the RAW number (the `tpl_documents`
78
- * register: a right-aligned "Size" column, formatted at display) — not a crammed
79
- * `FileRow` meta string, which sorts wrong ("8.4 MB" < "96 KB"). */
77
+ * dedicated sortable `Table` column over the RAW number (the `tpl_record`
78
+ * documents register: a right-aligned "Size" column, formatted at display) —
79
+ * not a crammed `FileRow` meta string, which sorts wrong ("8.4 MB" < "96 KB"). */
80
80
  size?: number | null;
81
81
  /** ISO upload timestamp (date-added), resolved at serving time. Show as a
82
82
  * dedicated "Added" `Table` column (format with `formatDate`), sortable over the
package/dist/src/rpc.d.ts CHANGED
@@ -147,6 +147,18 @@ export declare function subscribeHostRefetch(cb: () => void): () => void;
147
147
  * standalone: the SDK reads the public endpoint's body directly.
148
148
  */
149
149
  export declare function rpcAgentRun(payload: AgentRunPayload, onText: (chunk: string) => void, onRunId?: (runId: string) => void): AgentRunHandle;
150
+ export interface AgentRunContinuePayload {
151
+ run_id: string;
152
+ tool_call_id: string;
153
+ /** The `ask_user_choice` output the user assembled (validated server-side). */
154
+ output: Record<string, unknown>;
155
+ }
156
+ /**
157
+ * Continue a PARKED (`awaiting_input`) agent run with the user's answer to its
158
+ * pending `ask_user_choice` call — streams the continuation leg exactly like
159
+ * `rpcAgentRun` streams the first.
160
+ */
161
+ export declare function rpcAgentRunContinue(payload: AgentRunContinuePayload, onText: (chunk: string) => void): AgentRunHandle;
150
162
  /**
151
163
  * The error message for a non-ok response. A genuine JSON error (a 4xx carrying
152
164
  * a `message`) surfaces verbatim; a non-JSON body (a gateway HTML page), any
package/dist/src/rpc.js CHANGED
@@ -218,6 +218,70 @@ function agentRunBridged(payload, onText, host, onRunId) {
218
218
  },
219
219
  };
220
220
  }
221
+ /**
222
+ * Continue a PARKED (`awaiting_input`) agent run with the user's answer to its
223
+ * pending `ask_user_choice` call — streams the continuation leg exactly like
224
+ * `rpcAgentRun` streams the first.
225
+ */
226
+ export function rpcAgentRunContinue(payload, onText) {
227
+ const hostOrigin = getHostOrigin();
228
+ if (hostOrigin) {
229
+ ensureListener();
230
+ const id = nextRpcId++;
231
+ let settleDone = () => { };
232
+ const done = new Promise((resolve, reject) => {
233
+ settleDone = resolve;
234
+ streaming.set(id, { onText, resolve, reject });
235
+ window.parent.postMessage({ id, op: "agentRunContinue", payload }, hostOrigin);
236
+ });
237
+ return {
238
+ done,
239
+ abort: () => {
240
+ if (!streaming.has(id))
241
+ return;
242
+ streaming.delete(id);
243
+ window.parent.postMessage({ id, type: "abort" }, hostOrigin);
244
+ settleDone();
245
+ },
246
+ };
247
+ }
248
+ const controller = new AbortController();
249
+ const done = (async () => {
250
+ const { app_id } = await boot();
251
+ const headers = { "content-type": "application/json" };
252
+ if (sessionToken)
253
+ headers["authorization"] = `Bearer ${sessionToken}`;
254
+ const res = await fetch(`${API_BASE}/v1/apps/${app_id}/agent-runs/${encodeURIComponent(payload.run_id)}/continue`, {
255
+ method: "POST",
256
+ headers,
257
+ body: JSON.stringify({ tool_call_id: payload.tool_call_id, output: payload.output }),
258
+ signal: controller.signal,
259
+ });
260
+ if (!res.ok || !res.body) {
261
+ const text = await res.text().catch(() => "");
262
+ throw new Error(text || `HTTP ${res.status}`);
263
+ }
264
+ const reader = res.body.getReader();
265
+ const decoder = new TextDecoder();
266
+ try {
267
+ for (;;) {
268
+ const { value, done: streamDone } = await reader.read();
269
+ if (streamDone)
270
+ break;
271
+ onText(decoder.decode(value, { stream: true }));
272
+ }
273
+ const tail = decoder.decode();
274
+ if (tail)
275
+ onText(tail);
276
+ }
277
+ catch (err) {
278
+ if (controller.signal.aborted)
279
+ return;
280
+ throw err;
281
+ }
282
+ })();
283
+ return { done, abort: () => controller.abort() };
284
+ }
221
285
  function agentRunStandalone(payload, onText, onRunId) {
222
286
  const controller = new AbortController();
223
287
  const done = (async () => {
package/docs/ai.md CHANGED
@@ -52,7 +52,9 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
52
52
  | `run` | `(input, { sessionId, replace? }) => Promise<TOutput \| undefined>` | Start a run. Streams progress into the hook's state and resolves to the structured output (`undefined` for a free-text or failed run). **Single-flight:** while a run is in flight, calling it again returns the in-flight run's promise — an accidental double-press joins the first run instead of billing a second one (each dedup emits the `app_agent_run_deduped` analytics event). Pass `replace: true` to deliberately abort-and-restart; the replaced run still executes and bills server-side |
53
53
  | `cancel` | `() => void` | Stop the run **server-side** (saves tokens) and locally. Wire a user-facing Stop button to this |
54
54
  | `abort` | `() => void` | Stop listening **locally only** — the run keeps executing server-side and its result is still persisted. This is the unmount path (the hook calls it automatically on unmount) |
55
- | `status` | `"idle" \| "streaming" \| "completed" \| "error"` | Whole-run state. `abort`/`cancel` reset it to `"idle"` (and clear the partial transcript) |
55
+ | `status` | `"idle" \| "streaming" \| "awaiting_input" \| "completed" \| "error"` | Whole-run state. `awaiting_input` = the run is PARKED on a question the agent asked (see the ask-back section below). `abort`/`cancel` reset it to `"idle"` (and clear the partial transcript) |
56
+ | `pendingChoice` | `PendingChoice \| null` | The agent's pending question(s) — non-null exactly while `status` is `awaiting_input`. `questions` maps 1:1 onto `@lotics/ui`'s `ClarifyWizard` (`{question, options: {label, description}[], allow_custom}`) |
57
+ | `answerChoice` | `(answers: {value, custom}[]) => Promise<TOutput \| undefined>` | Answer the pending question(s) and CONTINUE the run — one entry per question, aligned by index (exactly what `ClarifyWizard`'s `onSubmit` yields). Streams the continuation into the same `parts`; resolves like `run`. Rejects when nothing is pending |
56
58
  | `parts` | `AgentUIPart[]` | The ordered live transcript as **ai-sdk `UIMessage.parts`** — answer prose, thinking, and tool calls, in stream order. The single source of truth for the feed; hand it straight to `@lotics/ui` `AgentRun` |
57
59
  | `text` | `string` | The agent's **answer prose** (every `text` part concatenated), accumulating live. Excludes thinking. For a free-text agent this IS the result |
58
60
  | `output` | `TOutput \| undefined` | The structured result once the run completes. `undefined` when the run produced none |
@@ -81,6 +83,36 @@ The terminal `submit_result` call is captured into `output`, **not** rendered as
81
83
 
82
84
  `AgentRun` renders thinking collapsed, groups consecutive tool calls, and expands each tool's input/output in place on press — all for free. Running it in a bounded container (a dialog, a panel)? Wrap it in `@lotics/ui`'s `FollowScroll` so the container follows the stream instead of letting new content grow below the fold. **Errors surface two ways:** a per-tool failure is an `output-error` part (amber dot, reason in the expanded Error panel — the feed flows on, exactly like a run that retried and recovered); a **breaking** error that killed the run lives in `run.error` (not in `parts`) — pass it as `error` and it renders as a terminal danger row. Never rebuild this feed by hand.
83
85
 
86
+ ### The agent asks back — `pendingChoice` / `answerChoice`
87
+
88
+ Every app agent carries the platform's MANDATORY interactive tool `ask_user_choice` —
89
+ nothing to declare. When the agent hits a genuine ambiguity mid-run it asks: the run PARKS
90
+ (`status: "awaiting_input"`, no tokens burning, resumable for an hour), the pending question(s)
91
+ surface as `pendingChoice`, and answering CONTINUES the same run — same `parts`, same session,
92
+ same eventual `output`:
93
+
94
+ ```tsx
95
+ const run = useAgentRun("importer");
96
+ // …
97
+ {run.pendingChoice ? (
98
+ <ClarifyWizard
99
+ questions={run.pendingChoice.questions.map((q) => ({
100
+ question: q.question,
101
+ answers: q.options.map((o) => ({ value: o.label, label: o.label, description: o.description })),
102
+ allowCustom: q.allow_custom,
103
+ }))}
104
+ onSubmit={(answers) => { void run.answerChoice(answers); }}
105
+ onCancel={() => run.cancel()}
106
+ />
107
+ ) : null}
108
+ ```
109
+
110
+ The ask renders in the feed as a settled tool row once answered (the answer rides its
111
+ on-demand reveal). `run()`'s promise resolves `undefined` when the run parks — the
112
+ continuation's promise (from `answerChoice`) carries the final output. `cancel()` on a parked
113
+ run settles it `aborted` immediately; an unanswered park expires after an hour (`error`,
114
+ "timed out waiting for an answer"). Multi-ask runs work — each answer may park again.
115
+
84
116
  ### `output` typing and the inner-field caveat
85
117
 
86
118
  The server validates the agent's submitted result strictly (unknown keys rejected, required fields present, types checked, `select` values bound to the declared options); an invalid submission is rejected back to the agent with the validation errors, and the agent retries. A run only settles **completed** with a result that passed this — so on a completed run, `output.items.map(...)` on a declared array field is safe. What that does **not** guarantee:
@@ -269,3 +301,4 @@ The floors below are when each capability shipped in `@lotics/app-sdk`; an app p
269
301
  | `parts` transcript (ai-sdk `UIMessagePart[]` — reasoning + per-tool `input`/`output`) | `@lotics/app-sdk` 0.54; renders with `@lotics/ui` ≥ 14.0 (`AgentRun` `parts` prop) |
270
302
  | `askAi` | `@lotics/app-sdk` 0.45 |
271
303
  | `useAiContext` (ambient-chat view state + auto query refetch on chat mutation) | `@lotics/app-sdk` 0.52 |
304
+ | The agent asks back (`pendingChoice`/`answerChoice`, `awaiting_input`) | `@lotics/app-sdk` 0.55 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.54.2",
3
+ "version": "0.55.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {