@lotics/app-sdk 0.53.0 → 0.54.1

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 `items` → `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`), `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
 
@@ -3,63 +3,28 @@
3
3
  *
4
4
  * The backend streams the AI-SDK "UI message" SSE protocol (the same wire format
5
5
  * the chat uses) — `data: <json>\n\n` frames, each a typed chunk, terminated by
6
- * `data: [DONE]`. The app SDK consumes it WITHOUT depending on the `ai` package
7
- * (the sandboxed app bundle stays lean): we read the handful of chunk types that
8
- * matter for a run feed and fold them into `AgentRunState`. Unknown chunk types
9
- * are ignored, so an AI-SDK version that adds chunk kinds never breaks the SDK.
6
+ * `data: [DONE]`. We fold the chunk types that matter into an ordered `parts` array
7
+ * the SAME ai-sdk `UIMessagePart[]` shape `@lotics/ui`'s `AgentRun` renders, so
8
+ * `<AgentRun parts={run.parts} />` needs no adapter. `ai` is a TYPE-ONLY import here
9
+ * (tool calls are hand-built as concrete `dynamic-tool` parts); NO `ai` runtime
10
+ * enters the sandboxed app bundle. Unknown chunk types are ignored, so a newer
11
+ * AI-SDK never breaks the SDK.
10
12
  *
11
- * Chunks fold into an ordered `items` transcript answer prose, thinking
12
- * (`reasoning`, kept a distinct segment so the UI can collapse it), and tool steps
13
- * (each carrying its `input`/`output`/`status` for an on-demand reveal), in stream
14
- * order that feeds `@lotics/ui` `AgentRun` directly. An agent can be either kind:
15
- * a STRUCTURED agent (declares `outputs`) emits its result as the input of the
16
- * injected `submit_result` tool → `output`; a FREE-TEXT agent's result is its prose
17
- * → the transcript's text. `output` is NEVER the free-text (see
18
- * `AgentRunState.output`). Pure + reducer-shaped so it is fully unit-testable
19
- * against recorded frames — no live model needed.
13
+ * An agent can be either kind: a STRUCTURED agent (declares `outputs`) emits its
14
+ * result as the input of the injected `submit_result` tool `output` (NOT a part);
15
+ * a FREE-TEXT agent's result is its prose → a text part. `output` is NEVER the
16
+ * free-text (see `AgentRunState.output`). Pure + reducer-shaped so it is fully
17
+ * unit-testable against recorded frames no live model needed.
20
18
  */
21
- export interface AgentRunStep {
22
- id: string;
23
- label: string;
24
- detail?: string;
25
- status: "running" | "done" | "error";
26
- kind?: "step" | "tool";
27
- /** The tool call's input (arguments). Carried for on-demand reveal, NOT shown in
28
- * the feed — `@lotics/ui` `AgentRun` renders it in a press-to-open peek. */
29
- input?: unknown;
30
- /** The tool call's result, once it returns. On-demand reveal, same as `input`. */
31
- output?: unknown;
32
- /** The tool failure message, when `status` is `"error"`. */
33
- errorText?: string;
34
- /** Running count of streamed argument characters. While a tool call's arguments
35
- * are still generating, the reducer writes a live size into `detail` ("18 KB")
36
- * off this accumulator, so a long generation reads as progressing, not frozen;
37
- * both are cleared when the call settles. */
38
- streamedChars?: number;
39
- }
40
- /** One entry in the ordered run transcript, in the order it streamed: the agent's
41
- * answer prose (`text`), its thinking (`reasoning` — shown collapsed, revealed on
42
- * demand), or a tool it ran (`step`). This is structurally the `AgentRunItem` that
43
- * `@lotics/ui`'s `AgentRun` takes, so `<AgentRun items={run.items} state={…} />`
44
- * needs no adapter — the SDK can't import the UI type (it must stay off the
45
- * react-native-web dep tree), it mirrors the shape. */
46
- export type AgentRunItem = {
47
- type: "text";
48
- id: string;
49
- text: string;
50
- } | {
51
- type: "reasoning";
52
- id: string;
53
- text: string;
54
- } | ({
55
- type: "step";
56
- } & AgentRunStep);
19
+ import type { UIMessagePart, UIDataTypes, UITools } from "ai";
20
+ /** An ai-sdk message part — the render model, tool-set-agnostic. */
21
+ export type AgentUIPart = UIMessagePart<UIDataTypes, UITools>;
57
22
  export interface AgentRunState {
58
23
  status: "streaming" | "completed" | "error";
59
- /** The ordered transcript — prose interleaved with the tool steps, as it
60
- * streamed. The single source of truth for the feed; `useAgentRun` derives the
61
- * backward-compat `text` / `steps` from it. */
62
- items: AgentRunItem[];
24
+ /** The ordered transcript as ai-sdk `parts` — prose interleaved with the tool
25
+ * calls (`dynamic-tool` parts), as it streamed. The single source of truth for
26
+ * the feed; `useAgentRun` derives `text` / `steps` from it. */
27
+ parts: AgentUIPart[];
63
28
  /** The structured result — the `submit_result` tool's input — or `undefined`.
64
29
  * NEVER the free-text answer: a free-text agent (or a structured one that
65
30
  * finished without submitting) has no `output`; its answer is the transcript's
@@ -3,20 +3,18 @@
3
3
  *
4
4
  * The backend streams the AI-SDK "UI message" SSE protocol (the same wire format
5
5
  * the chat uses) — `data: <json>\n\n` frames, each a typed chunk, terminated by
6
- * `data: [DONE]`. The app SDK consumes it WITHOUT depending on the `ai` package
7
- * (the sandboxed app bundle stays lean): we read the handful of chunk types that
8
- * matter for a run feed and fold them into `AgentRunState`. Unknown chunk types
9
- * are ignored, so an AI-SDK version that adds chunk kinds never breaks the SDK.
6
+ * `data: [DONE]`. We fold the chunk types that matter into an ordered `parts` array
7
+ * the SAME ai-sdk `UIMessagePart[]` shape `@lotics/ui`'s `AgentRun` renders, so
8
+ * `<AgentRun parts={run.parts} />` needs no adapter. `ai` is a TYPE-ONLY import here
9
+ * (tool calls are hand-built as concrete `dynamic-tool` parts); NO `ai` runtime
10
+ * enters the sandboxed app bundle. Unknown chunk types are ignored, so a newer
11
+ * AI-SDK never breaks the SDK.
10
12
  *
11
- * Chunks fold into an ordered `items` transcript answer prose, thinking
12
- * (`reasoning`, kept a distinct segment so the UI can collapse it), and tool steps
13
- * (each carrying its `input`/`output`/`status` for an on-demand reveal), in stream
14
- * order that feeds `@lotics/ui` `AgentRun` directly. An agent can be either kind:
15
- * a STRUCTURED agent (declares `outputs`) emits its result as the input of the
16
- * injected `submit_result` tool → `output`; a FREE-TEXT agent's result is its prose
17
- * → the transcript's text. `output` is NEVER the free-text (see
18
- * `AgentRunState.output`). Pure + reducer-shaped so it is fully unit-testable
19
- * against recorded frames — no live model needed.
13
+ * An agent can be either kind: a STRUCTURED agent (declares `outputs`) emits its
14
+ * result as the input of the injected `submit_result` tool `output` (NOT a part);
15
+ * a FREE-TEXT agent's result is its prose → a text part. `output` is NEVER the
16
+ * free-text (see `AgentRunState.output`). Pure + reducer-shaped so it is fully
17
+ * unit-testable against recorded frames no live model needed.
20
18
  */
21
19
  /** The tool the backend injects to carry a typed structured result. */
22
20
  const SUBMIT_TOOL = "submit_result";
@@ -39,94 +37,89 @@ export function adoptSettledRun(state, settled) {
39
37
  return { ...state, status: "error", error: settled.error_message ?? "The run was stopped." };
40
38
  }
41
39
  export function initialAgentRunState() {
42
- return { status: "streaming", items: [] };
40
+ return { status: "streaming", parts: [] };
43
41
  }
44
- /** A live size for a step's still-streaming arguments B under 1 KB, else KB. */
45
- function formatStreamSize(chars) {
46
- return chars < 1024 ? `${chars} B` : `${Math.round(chars / 1024)} KB`;
47
- }
48
- /** Grow the trailing prose segment of the given kind, or open a new one (after a
49
- * tool ran, or when the kind flips text↔reasoning) — so the transcript interleaves
42
+ /** Grow the trailing prose part of the given kind, or open a new one (after a tool
43
+ * ran, or when the kind flips text↔reasoning) — so the transcript interleaves
50
44
  * answer prose, thinking, and tools in the order they streamed. */
51
- function appendProse(items, kind, piece) {
52
- const last = items[items.length - 1];
53
- if (last?.type === kind) {
54
- return [...items.slice(0, -1), { ...last, text: last.text + piece }];
45
+ function appendProse(parts, kind, piece) {
46
+ const last = parts[parts.length - 1];
47
+ if (last && last.type === kind) {
48
+ return [...parts.slice(0, -1), { ...last, text: last.text + piece }];
55
49
  }
56
- return [...items, { type: kind, id: `${kind}-${items.length}`, text: piece }];
50
+ const opened = kind === "text" ? { type: "text", text: piece, state: "streaming" } : { type: "reasoning", text: piece, state: "streaming" };
51
+ return [...parts, opened];
57
52
  }
58
- /** Apply an update to the tool step with the given call id (by identity), leaving
59
- * the rest untouched. A missing id (older frame) or no match is a no-op. */
60
- function updateStep(items, toolCallId, fn) {
53
+ /** Apply an update to the `dynamic-tool` part with the given call id (by identity),
54
+ * leaving the rest untouched. A missing id (older frame) or no match is a no-op. */
55
+ function updateTool(parts, toolCallId, fn) {
61
56
  if (!toolCallId)
62
- return items;
63
- return items.map((it) => (it.type === "step" && it.id === toolCallId ? fn(it) : it));
57
+ return parts;
58
+ return parts.map((p) => (p.type === "dynamic-tool" && p.toolCallId === toolCallId ? fn(p) : p));
64
59
  }
65
60
  /** Fold one chunk into the run state. Returns a new state (immutable). */
66
61
  export function reduceAgentChunk(state, chunk) {
67
62
  switch (chunk.type) {
68
63
  case "text-delta": {
69
64
  const piece = chunk.delta ?? "";
70
- return piece ? { ...state, items: appendProse(state.items, "text", piece) } : state;
65
+ return piece ? { ...state, parts: appendProse(state.parts, "text", piece) } : state;
71
66
  }
72
67
  case "reasoning-delta": {
73
- // Thinking is its OWN segment (not folded into the answer prose) so the UI can
68
+ // Thinking is its OWN part (not folded into the answer prose) so the UI can
74
69
  // keep it collapsed / revealed-on-demand rather than inline in the answer.
75
70
  const piece = chunk.delta ?? "";
76
- return piece ? { ...state, items: appendProse(state.items, "reasoning", piece) } : state;
71
+ return piece ? { ...state, parts: appendProse(state.parts, "reasoning", piece) } : state;
77
72
  }
78
73
  case "tool-input-start":
79
74
  case "tool-input-available": {
80
75
  const name = chunk.toolName ?? "tool";
81
76
  if (name === SUBMIT_TOOL) {
82
- // The agent emitted its structured result — not a transcript step.
77
+ // The agent emitted its structured result — routed to `output`, not a part.
83
78
  return { ...state, output: chunk.input };
84
79
  }
85
- const id = chunk.toolCallId ?? `${name}-${state.items.length}`;
86
- // Upsert by call id: the step opens "running" the moment the call fires and
87
- // settles when its output arrives (below), so the feed shows a live tool, not
88
- // an instantly-done one. `input` rides along for the on-demand peek.
89
- const existing = state.items.some((it) => it.type === "step" && it.id === id);
80
+ const id = chunk.toolCallId ?? `${name}-${state.parts.length}`;
81
+ // Upsert by call id: the tool part opens "input-available" (a running step in
82
+ // the feed) the moment the call fires and settles when its output arrives
83
+ // (below), so the feed shows a live tool, not an instantly-done one.
84
+ const existing = state.parts.some((p) => p.type === "dynamic-tool" && p.toolCallId === id);
90
85
  if (existing) {
91
- return { ...state, items: updateStep(state.items, id, (s) => ({ ...s, label: s.label || name, input: chunk.input ?? s.input })) };
86
+ return { ...state, parts: updateTool(state.parts, id, (p) => ({ type: "dynamic-tool", toolName: p.toolName || name, toolCallId: id, state: "input-available", input: chunk.input ?? p.input })) };
92
87
  }
93
88
  return {
94
89
  ...state,
95
- items: [...state.items, { type: "step", id, label: name, kind: "tool", status: "running", input: chunk.input }],
96
- };
97
- }
98
- case "tool-input-delta": {
99
- // A tool's arguments streaming in — grow the step's live size so a long
100
- // generation reads as progressing. `submit_result` created no step (it sets
101
- // `output`), so its deltas find no matching step and this no-ops.
102
- const len = (chunk.inputTextDelta ?? "").length;
103
- if (!len)
104
- return state;
105
- return {
106
- ...state,
107
- items: updateStep(state.items, chunk.toolCallId, (s) => {
108
- const chars = (s.streamedChars ?? 0) + len;
109
- return { ...s, streamedChars: chars, detail: formatStreamSize(chars) };
110
- }),
90
+ parts: [...state.parts, { type: "dynamic-tool", toolName: name, toolCallId: id, state: "input-available", input: chunk.input }],
111
91
  };
112
92
  }
93
+ case "tool-input-delta":
94
+ // A tool's arguments streaming in. The ai-parts model carries no live size, so
95
+ // this is a no-op — the part already reads as running.
96
+ return state;
113
97
  case "tool-output-available":
114
- // Settled drop the live streaming size; the row is just label + done.
115
- return { ...state, items: updateStep(state.items, chunk.toolCallId, (s) => ({ ...s, status: "done", output: chunk.output, detail: undefined, streamedChars: undefined })) };
98
+ return { ...state, parts: updateTool(state.parts, chunk.toolCallId, (p) => ({ type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-available", input: p.input, output: chunk.output })) };
116
99
  case "tool-output-error":
117
- return { ...state, items: updateStep(state.items, chunk.toolCallId, (s) => ({ ...s, status: "error", errorText: chunk.errorText ?? "The tool failed." })) };
100
+ return { ...state, parts: updateTool(state.parts, chunk.toolCallId, (p) => ({ type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-error", input: p.input, errorText: chunk.errorText ?? "The tool failed." })) };
118
101
  case "error":
119
102
  return { ...state, status: "error", error: chunk.errorText ?? "The run failed." };
120
103
  case "abort":
121
104
  return { ...state, status: state.status === "streaming" ? "error" : state.status, error: state.error ?? "The run was stopped." };
122
105
  case "finish": {
123
- // Settle the status, and settle any tool still "running" (its output frame
124
- // never came) to "done" so nothing spins forever. `output` is whatever
125
- // `submit_result` set (else undefined) never the accumulated text: a
126
- // structured-output consumer reads `output.<field>`, so a stray free-text
127
- // string there would crash it. The free-text answer is always in `text`.
128
- const items = state.items.map((it) => (it.type === "step" && it.status === "running" ? { ...it, status: "done" } : it));
129
- return { ...state, status: state.status === "error" ? "error" : "completed", items };
106
+ // Settle the status, and settle anything still streaming so nothing reads as
107
+ // in-progress on a completed run: a tool with no output frame → output-available
108
+ // (its output never came); a text/reasoning part still "streaming" → "done" (the
109
+ // stream's `text-end`/`reasoning-end` markers aren't tracked per-part). `output`
110
+ // is whatever `submit_result` set (else undefined) NEVER the accumulated text:
111
+ // a structured-output consumer reads `output.<field>`, so a stray free-text
112
+ // string there would crash it. The free-text answer is always in a text part.
113
+ const parts = state.parts.map((p) => {
114
+ if (p.type === "dynamic-tool" && (p.state === "input-available" || p.state === "input-streaming")) {
115
+ return { type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-available", input: p.input, output: undefined };
116
+ }
117
+ if ((p.type === "text" || p.type === "reasoning") && p.state === "streaming") {
118
+ return { ...p, state: "done" };
119
+ }
120
+ return p;
121
+ });
122
+ return { ...state, status: state.status === "error" ? "error" : "completed", parts };
130
123
  }
131
124
  default:
132
125
  return state;
@@ -1,9 +1,9 @@
1
1
  import { type AiContextValue } from "./rpc.js";
2
- import { type AgentRunStep, type AgentRunItem } from "./agent_stream.js";
2
+ import { type AgentUIPart } 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 { AgentRunStep, AgentRunState, AgentRunItem } from "./agent_stream.js";
6
+ export type { AgentRunState, AgentUIPart } from "./agent_stream.js";
7
7
  /** Fields shared by every query hook's return value. */
8
8
  interface QueryStateBase {
9
9
  /**
@@ -470,20 +470,15 @@ export interface UseAgentRun<TInput, TOutput> {
470
470
  * user-facing "Stop" button to this, not `abort`. */
471
471
  cancel: () => void;
472
472
  status: "idle" | "streaming" | "completed" | "error";
473
- /** The ordered run transcript — prose interleaved with tool steps, in stream
474
- * order. Feed straight to `@lotics/ui` `AgentRun`:
475
- * `<AgentRun items={run.items} state={run.status === "streaming" ? "streaming" : run.status === "error" ? "error" : "done"} />`
473
+ /** The ordered run transcript as ai-sdk `parts` — prose interleaved with tool
474
+ * calls, in stream order. Feed straight to `@lotics/ui` `AgentRun`:
475
+ * `<AgentRun parts={run.parts} state={run.status === "streaming" ? "streaming" : run.status === "error" ? "error" : "done"} />`
476
476
  * — no hand-assembly. */
477
- items: AgentRunItem[];
478
- /** The agent's ANSWER prose (every `text` segment concatenated), accumulating
479
- * live — excludes thinking (`reasoning` is its own segment in `items`). For a
480
- * FREE-TEXT agent this IS the result; a structured agent's result is `output`.
481
- * Derived from `items`. */
477
+ parts: AgentUIPart[];
478
+ /** The agent's ANSWER prose (every `text` part concatenated), accumulating live —
479
+ * excludes thinking (`reasoning` is its own part). For a FREE-TEXT agent this IS
480
+ * the result; a structured agent's result is `output`. Derived from `parts`. */
482
481
  text: string;
483
- /** Only the tool/step items (each with its `input`/`output`/`status`) — backward
484
- * compat with the older steps-only `AgentRun`. Prefer `items` (it keeps the
485
- * text↔reasoning↔tool ordering the feed renders). Derived from `items`. */
486
- steps: AgentRunStep[];
487
482
  /** The structured result once the run completes — the agent's `submit_result`
488
483
  * output. `undefined` when the run produced none (a free-text agent, or one that
489
484
  * finished without submitting); a free-text answer lives in `text`, never here.
@@ -496,14 +491,14 @@ export interface UseAgentRun<TInput, TOutput> {
496
491
  /**
497
492
  * Run a streaming agent declared in `package.json` lotics.agents and invoked by
498
493
  * alias. `run(input, { sessionId })` starts it; progress streams into `status` +
499
- * the ordered `items` transcript (feed straight to `@lotics/ui` `AgentRun`). A
494
+ * the ordered `parts` transcript (feed straight to `@lotics/ui` `AgentRun`). A
500
495
  * STRUCTURED agent's result lands in `output`; a FREE-TEXT agent's answer is the
501
496
  * transcript's prose (`text`). Read the session's history with `useAgentRuns`.
502
497
  *
503
498
  * ```tsx
504
499
  * const recognize = useAgentRun("recognize");
505
500
  * await recognize.run({ image_file_id }, { sessionId });
506
- * // <AgentRun items={recognize.items} state={recognize.status === "streaming" ? "streaming" : "done"} />
501
+ * // <AgentRun parts={recognize.parts} state={recognize.status === "streaming" ? "streaming" : "done"} />
507
502
  * // then read recognize.output (structured) or recognize.text (free-text)
508
503
  * ```
509
504
  */
package/dist/src/hooks.js CHANGED
@@ -586,26 +586,24 @@ export function useAgentRun(alias) {
586
586
  void rpc("agentRun.cancel", { run_id: runId }).catch(() => { });
587
587
  handleRef.current?.abort();
588
588
  }, []);
589
- // `items` is the source of truth; `text` (all prose) and `steps` (tool items
590
- // only) are the backward-compat views derived from it.
591
- const items = state?.items ?? EMPTY_ITEMS;
592
- const text = useMemo(() => items.reduce((acc, i) => (i.type === "text" ? acc + i.text : acc), ""), [items]);
593
- const steps = useMemo(() => items.flatMap((i) => (i.type === "step" ? [{ id: i.id, label: i.label, detail: i.detail, status: i.status, kind: i.kind, input: i.input, output: i.output, errorText: i.errorText }] : [])), [items]);
589
+ // `parts` is the source of truth; `text` (all prose concatenated) is the derived
590
+ // answer view.
591
+ const parts = state?.parts ?? EMPTY_PARTS;
592
+ const text = useMemo(() => parts.reduce((acc, p) => (p.type === "text" ? acc + p.text : acc), ""), [parts]);
594
593
  return {
595
594
  run,
596
595
  abort,
597
596
  cancel,
598
597
  status: state?.status ?? "idle",
599
- items,
598
+ parts,
600
599
  text,
601
- steps,
602
600
  output: state?.output,
603
601
  error: state?.error,
604
602
  };
605
603
  }
606
- /** Stable empty transcript so an idle hook returns a constant `items` reference
604
+ /** Stable empty transcript so an idle hook returns a constant `parts` reference
607
605
  * (no new [] each render → dependents don't re-run needlessly). */
608
- const EMPTY_ITEMS = [];
606
+ const EMPTY_PARTS = [];
609
607
  /**
610
608
  * Bounded PAST the server-side max-run cap (20 min) plus settle grace. The
611
609
  * server guarantees a LIVE run settles by its own cap timer, so a row still
@@ -17,7 +17,7 @@
17
17
  export { mount } from "./mount.js";
18
18
  export type { MountOptions } from "./mount.js";
19
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, AgentRunStep, AgentRunItem, FieldOptions, FieldOptionsState, FieldOptionsOptions, } 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";
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/docs/ai.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AI in apps
2
2
 
3
- An app has two AI surfaces, and they answer different questions. **`useAgentRun(alias)`** runs an agent *declared on the app* — a streaming, tool-looping run whose result lands back **in the app** (a typed structured output, or free-text prose) for the app to review and commit through its own [workflows](./mutations.md). **`askAi(args)`** is a *handoff* — it opens the Lotics chat messenger seeded with files, records, and a prefilled prompt, and the outcome lands **in chat**, under the signed-in member's control. Beyond those two, **`useAiContext(slot, context)`** feeds the member's *ambient* chat agent — the one riding alongside the app — a snapshot of what the current screen is showing, so a question the member asks there resolves against what they're looking at. Read this doc when adding any AI-driven feature to an app; read [security](./security.md) first for the authority model agent runs execute under. Exact signatures: `dist/src/hooks.d.ts` (`useAgentRun`, `useAgentRuns`, `useAiContext`), `dist/src/agent_stream.d.ts` (`AgentRunItem`, `AgentRunStep`, `AgentRunState`), `dist/src/ask_ai.d.ts` (`AskAiArgs`).
3
+ An app has two AI surfaces, and they answer different questions. **`useAgentRun(alias)`** runs an agent *declared on the app* — a streaming, tool-looping run whose result lands back **in the app** (a typed structured output, or free-text prose) for the app to review and commit through its own [workflows](./mutations.md). **`askAi(args)`** is a *handoff* — it opens the Lotics chat messenger seeded with files, records, and a prefilled prompt, and the outcome lands **in chat**, under the signed-in member's control. Beyond those two, **`useAiContext(slot, context)`** feeds the member's *ambient* chat agent — the one riding alongside the app — a snapshot of what the current screen is showing, so a question the member asks there resolves against what they're looking at. Read this doc when adding any AI-driven feature to an app; read [security](./security.md) first for the authority model agent runs execute under. Exact signatures: `dist/src/hooks.d.ts` (`useAgentRun`, `useAgentRuns`, `useAiContext`), `dist/src/agent_stream.d.ts` (`AgentRunState`, `AgentUIPart`), `dist/src/ask_ai.d.ts` (`AskAiArgs`).
4
4
 
5
5
  ## Choosing the surface — the fields-vs-file razor
6
6
 
@@ -41,7 +41,7 @@ import { useAgentRun } from "@lotics/app-sdk";
41
41
 
42
42
  const recognize = useAgentRun("recognize");
43
43
  await recognize.run({ image_file_id: fileId }, { sessionId });
44
- // live: recognize.status, recognize.items
44
+ // live: recognize.status, recognize.parts
45
45
  // done: recognize.output (structured) or recognize.text (free-text)
46
46
  ```
47
47
 
@@ -53,33 +53,33 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
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
55
  | `status` | `"idle" \| "streaming" \| "completed" \| "error"` | Whole-run state. `abort`/`cancel` reset it to `"idle"` (and clear the partial transcript) |
56
- | `items` | `AgentRunItem[]` | The ordered live transcript — answer prose, thinking, and tool steps, in stream order. The single source of truth for the feed |
57
- | `text` | `string` | The agent's **answer prose** (every `text` segment concatenated), accumulating live. Excludes thinking. For a free-text agent this IS the result |
58
- | `steps` | `AgentRunStep[]` | Only the tool/step items — a backward-compat view derived from `items`. Prefer `items` (it keeps the text↔reasoning↔tool ordering) |
56
+ | `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
+ | `text` | `string` | The agent's **answer prose** (every `text` part concatenated), accumulating live. Excludes thinking. For a free-text agent this IS the result |
59
58
  | `output` | `TOutput \| undefined` | The structured result once the run completes. `undefined` when the run produced none |
60
59
  | `error` | `string \| undefined` | The failure message when `status` is `"error"` |
61
60
 
62
- ### The `items` transcript → `@lotics/ui` `AgentRun`
61
+ ### The `parts` transcript → `@lotics/ui` `AgentRun`
63
62
 
64
- `AgentRunItem` is a discriminated union in stream order:
63
+ `parts` is the **ai-sdk `UIMessagePart[]`** shape — the same wire model chat and app agents both emit, so there is no bespoke transcript type to keep in sync. The reducer folds the run's SSE chunks into it, in stream order:
65
64
 
66
- | `type` | Carries | Rendering intent |
65
+ | Part `type` | Carries | Rendering intent |
67
66
  |---|---|---|
68
- | `"text"` | `id`, `text` | Answer prose, grows as it streams |
69
- | `"reasoning"` | `id`, `text` | The agent's thinking — a **distinct segment**, kept out of `text`, so the UI can show it collapsed / revealed on demand |
70
- | `"step"` | `AgentRunStep`: `id`, `label` (raw tool name), `status` (`"running" \| "done" \| "error"`), `kind`, `input`, `output`, `errorText`, `detail`, `streamedChars` | One tool call — opens `"running"` the moment it fires, settles when its result arrives. `input`/`output` ride along for an on-demand reveal, not for the feed. While a call's arguments are still generating, `detail` carries a live size ("18 KB", accumulated in `streamedChars`) so a long generation reads as progressing; both clear when the call's output arrives (a call that settles `"error"` keeps its last size) |
67
+ | `"text"` | `text`, `state` | Answer prose, grows as it streams |
68
+ | `"reasoning"` | `text`, `state` | The agent's thinking — a **distinct part**, kept out of `text`, so `AgentRun` shows it collapsed / revealed on demand. Only produced when the agent's declared model supports thinking (**Sonnet / Opus**, not Haiku) — a Haiku agent never emits reasoning |
69
+ | `"dynamic-tool"` | `toolName`, `toolCallId`, `state` (ai's tool lifecycle `input-available` `output-available` / `output-error`), `input`, `output`, `errorText` | One tool call — opens running the moment it fires, settles when its result arrives. `input`/`output` ride along for `AgentRun`'s on-demand reveal, not for the feed row |
71
70
 
72
- The shape is structurally the `AgentRunItem` that `@lotics/ui`'s `AgentRun` component takes, so the pairing needs **no adapter and no hand-assembly**:
71
+ The terminal `submit_result` call is captured into `output`, **not** rendered as a tool part (structured agents therefore have no visible final step). Source/file parts aren't emitted by app agents. `parts` is exactly what `@lotics/ui` `AgentRun` renders, so the pairing needs **no adapter and no hand-assembly**:
73
72
 
74
73
  ```tsx
75
74
  <AgentRun
76
- items={run.items}
75
+ parts={run.parts}
77
76
  state={run.status === "streaming" ? "streaming" : run.status === "error" ? "error" : "done"}
77
+ error={run.error} // the breaking error — a terminal danger row
78
78
  labelForTool={(name) => toolLabels[name]} // optional: localize tool labels
79
79
  />
80
80
  ```
81
81
 
82
- `AgentRun` renders thinking collapsed, groups consecutive tool steps, and shows each tool's input/output in a press-to-open peek — all for free. Never rebuild this feed from `text` + `steps`.
82
+ `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
83
 
84
84
  ### `output` typing and the inner-field caveat
85
85
 
@@ -266,7 +266,6 @@ The floors below are when each capability shipped in `@lotics/app-sdk`; an app p
266
266
  |---|---|
267
267
  | `useAgentRun` (with `abort`) + `useAgentRuns` | `@lotics/app-sdk` 0.34 |
268
268
  | `cancel()` (server-side stop) + connection-drop recovery | `@lotics/app-sdk` 0.37 |
269
- | `items` transcript (reasoning segments + per-tool `input`/`output`) | `@lotics/app-sdk` 0.43; rendering pairs with `@lotics/ui` ≥ 7.13 (`AgentRun` `items` prop) |
270
- | Live streamed-argument size on a running step (`detail`) | `@lotics/app-sdk` 0.44 |
269
+ | `parts` transcript (ai-sdk `UIMessagePart[]` — reasoning + per-tool `input`/`output`) | `@lotics/app-sdk` 0.54; renders with `@lotics/ui` ≥ 14.0 (`AgentRun` `parts` prop) |
271
270
  | `askAi` | `@lotics/app-sdk` 0.45 |
272
271
  | `useAiContext` (ambient-chat view state + auto query refetch on chat mutation) | `@lotics/app-sdk` 0.52 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.53.0",
3
+ "version": "0.54.1",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {
@@ -30,11 +30,15 @@
30
30
  "swr": "^2.4.1"
31
31
  },
32
32
  "peerDependencies": {
33
+ "ai": ">=7.0.0",
33
34
  "react": "^19.2.0",
34
35
  "react-dom": "^19.2.0",
35
36
  "react-router-dom": "^7.0.0"
36
37
  },
37
38
  "peerDependenciesMeta": {
39
+ "ai": {
40
+ "optional": true
41
+ },
38
42
  "react-router-dom": {
39
43
  "optional": true
40
44
  }