@lotics/app-sdk 0.42.0 → 0.43.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
@@ -55,7 +55,10 @@ Pick by intent. (→ open the `.d.ts` for the exact signature.)
55
55
  to a `DisplayFile` (snake → camel — `mime_type`→`mimeType`, `preview_url`→`url` — the app owns this
56
56
  data→UI adapter; the SDK never imports `@lotics/ui`) for
57
57
  `<FileThumbnail file={{ id, filename, mimeType, url }} uploading={f.status === "uploading"} />`;
58
- `sendDisabled` gates on `uploading` and the send payload is `fileIds`. Don't hand-roll
58
+ `sendDisabled` gates on `uploading` and the send payload is `fileIds`. To persist several files into ONE
59
+ record, declare the workflow input `{ type: "file", multi: true }` — the body receives `fileIds` as
60
+ `ReadonlyArray<FileId>` and writes it straight to a `files` field (a single `file` input is one id → wrap `[id]`).
61
+ Don't hand-roll
59
62
  `createObjectURL`/upload/revoke per app. For a full add-files SCREEN (not the composer pill), map
60
63
  each `AttachedFile` to a `FileUpload` (ready → `{ status: "complete", id, file }`, else
61
64
  `{ status, id, filename, mimeType: mime_type, previewUrl: preview_url }`) and feed `@lotics/ui`
@@ -71,6 +74,23 @@ Pick by intent. (→ open the `.d.ts` for the exact signature.)
71
74
  - **Who's viewing** — **`useViewer()`** → the signed-in member (the *viewed* member under "View as").
72
75
  Display-only — personalize or attribute; **never** scope rows by it (use an `is_current_member`
73
76
  query filter server-side). Returns null for an anonymous public visitor.
77
+ - **Run an AI agent** — **`useAgentRun(alias)`** → `{ run, cancel, abort, status, items, text, steps,
78
+ output, error }` (agent declared in `package.json#lotics.agents`, authored via `set_app_agent`).
79
+ `run(input, { sessionId })` streams it. TWO kinds, both first-class: a **structured** agent (declares
80
+ `outputs`) lands its typed result in **`output`** (the `submit_result` shape) — `undefined` if the run
81
+ produced none, **never a stray string** (so a present `output` is the declared shape; still validate
82
+ untrusted INNER fields the model authored, e.g. an array that may be missing/mistyped); a **free-text**
83
+ agent's answer is the transcript prose (**`text`**). Feed the live **`items`** transcript straight to
84
+ `@lotics/ui` `AgentRun` — `<AgentRun items={run.items} state={run.status === "streaming" ? "streaming"
85
+ : run.status === "error" ? "error" : "done"} labelForTool={…} />` — NO hand-assembly (`text`/`steps`
86
+ are backward-compat views of `items`). The transcript carries the agent's **thinking** (a distinct
87
+ `reasoning` segment, kept OUT of `text`) and each tool's **input / output / running→done→error state**;
88
+ `AgentRun` shows thinking collapsed and tool I/O in a press-to-open peek (revealed on demand, not in the
89
+ feed) — all for free, no extra wiring. Wire a Stop button to **`cancel()`** (stops the run server-side,
90
+ saves tokens; `abort()` is local-only, for unmount). `run()` THROWS on quota/network — try/catch +
91
+ surface. Needs an authenticated member (embedded apps only, no anonymous runs). Image/PDF `file`
92
+ inputs are materialized into the agent's vision — pair with `useFileUpload`. `items`/`cancel` need
93
+ `@lotics/app-sdk` ≥ 0.43; the reasoning + per-tool I/O reveal needs `@lotics/ui` ≥ 7.13.
74
94
  - **Device location** — **`requestGeofencedLocation(zones)`** → a structured outcome
75
95
  `{ ok:true, coords } | { ok:false, reason:"denied"|"unavailable"|"outside" }`; **`isWithinZone`**
76
96
  is the pure check. Read directly in the iframe (host delegates the permission); the gate is
@@ -8,10 +8,15 @@
8
8
  * matter for a run feed and fold them into `AgentRunState`. Unknown chunk types
9
9
  * are ignored, so an AI-SDK version that adds chunk kinds never breaks the SDK.
10
10
  *
11
- * The structured result is the input of the agent's `submit_result` tool call
12
- * (the backend injects that tool when the agent declares `outputs`); a free-text
13
- * agent's result is the accumulated text. Pure + reducer-shaped so it is fully
14
- * unit-testable against recorded frames no live model needed.
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.
15
20
  */
16
21
  export interface AgentRunStep {
17
22
  id: string;
@@ -19,14 +24,42 @@ export interface AgentRunStep {
19
24
  detail?: string;
20
25
  status: "running" | "done" | "error";
21
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;
22
34
  }
35
+ /** One entry in the ordered run transcript, in the order it streamed: the agent's
36
+ * answer prose (`text`), its thinking (`reasoning` — shown collapsed, revealed on
37
+ * demand), or a tool it ran (`step`). This is structurally the `AgentRunItem` that
38
+ * `@lotics/ui`'s `AgentRun` takes, so `<AgentRun items={run.items} state={…} />`
39
+ * needs no adapter — the SDK can't import the UI type (it must stay off the
40
+ * react-native-web dep tree), it mirrors the shape. */
41
+ export type AgentRunItem = {
42
+ type: "text";
43
+ id: string;
44
+ text: string;
45
+ } | {
46
+ type: "reasoning";
47
+ id: string;
48
+ text: string;
49
+ } | ({
50
+ type: "step";
51
+ } & AgentRunStep);
23
52
  export interface AgentRunState {
24
53
  status: "streaming" | "completed" | "error";
25
- /** The agent's streamed reasoning / answer text, accumulated. */
26
- text: string;
27
- /** Tool calls the agent made, in order (excludes the internal `submit_result`). */
28
- steps: AgentRunStep[];
29
- /** The structured result — `submit_result`'s input, or (free-text) the final text. */
54
+ /** The ordered transcript prose interleaved with the tool steps, as it
55
+ * streamed. The single source of truth for the feed; `useAgentRun` derives the
56
+ * backward-compat `text` / `steps` from it. */
57
+ items: AgentRunItem[];
58
+ /** The structured result — the `submit_result` tool's input or `undefined`.
59
+ * NEVER the free-text answer: a free-text agent (or a structured one that
60
+ * finished without submitting) has no `output`; its answer is the transcript's
61
+ * text. Coercing text here would hand a consumer reading `output.<field>` a bare
62
+ * string, crashing it ("... is not iterable"). */
30
63
  output?: unknown;
31
64
  error?: string;
32
65
  }
@@ -38,6 +71,7 @@ interface Chunk {
38
71
  toolName?: string;
39
72
  toolCallId?: string;
40
73
  input?: unknown;
74
+ output?: unknown;
41
75
  errorText?: string;
42
76
  finishReason?: string;
43
77
  }
@@ -8,45 +8,87 @@
8
8
  * matter for a run feed and fold them into `AgentRunState`. Unknown chunk types
9
9
  * are ignored, so an AI-SDK version that adds chunk kinds never breaks the SDK.
10
10
  *
11
- * The structured result is the input of the agent's `submit_result` tool call
12
- * (the backend injects that tool when the agent declares `outputs`); a free-text
13
- * agent's result is the accumulated text. Pure + reducer-shaped so it is fully
14
- * unit-testable against recorded frames no live model needed.
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.
15
20
  */
16
21
  /** The tool the backend injects to carry a typed structured result. */
17
22
  const SUBMIT_TOOL = "submit_result";
18
23
  export function initialAgentRunState() {
19
- return { status: "streaming", text: "", steps: [] };
24
+ return { status: "streaming", items: [] };
25
+ }
26
+ /** Grow the trailing prose segment of the given kind, or open a new one (after a
27
+ * tool ran, or when the kind flips text↔reasoning) — so the transcript interleaves
28
+ * answer prose, thinking, and tools in the order they streamed. */
29
+ function appendProse(items, kind, piece) {
30
+ const last = items[items.length - 1];
31
+ if (last?.type === kind) {
32
+ return [...items.slice(0, -1), { ...last, text: last.text + piece }];
33
+ }
34
+ return [...items, { type: kind, id: `${kind}-${items.length}`, text: piece }];
35
+ }
36
+ /** Apply an update to the tool step with the given call id (by identity), leaving
37
+ * the rest untouched. A missing id (older frame) or no match is a no-op. */
38
+ function updateStep(items, toolCallId, fn) {
39
+ if (!toolCallId)
40
+ return items;
41
+ return items.map((it) => (it.type === "step" && it.id === toolCallId ? fn(it) : it));
20
42
  }
21
43
  /** Fold one chunk into the run state. Returns a new state (immutable). */
22
44
  export function reduceAgentChunk(state, chunk) {
23
45
  switch (chunk.type) {
24
- case "text-delta":
46
+ case "text-delta": {
47
+ const piece = chunk.delta ?? "";
48
+ return piece ? { ...state, items: appendProse(state.items, "text", piece) } : state;
49
+ }
25
50
  case "reasoning-delta": {
51
+ // Thinking is its OWN segment (not folded into the answer prose) so the UI can
52
+ // keep it collapsed / revealed-on-demand rather than inline in the answer.
26
53
  const piece = chunk.delta ?? "";
27
- return piece ? { ...state, text: state.text + piece } : state;
54
+ return piece ? { ...state, items: appendProse(state.items, "reasoning", piece) } : state;
28
55
  }
56
+ case "tool-input-start":
29
57
  case "tool-input-available": {
30
58
  const name = chunk.toolName ?? "tool";
31
59
  if (name === SUBMIT_TOOL) {
32
- // The agent emitted its structured result.
60
+ // The agent emitted its structured result — not a transcript step.
33
61
  return { ...state, output: chunk.input };
34
62
  }
63
+ const id = chunk.toolCallId ?? `${name}-${state.items.length}`;
64
+ // Upsert by call id: the step opens "running" the moment the call fires and
65
+ // settles when its output arrives (below), so the feed shows a live tool, not
66
+ // an instantly-done one. `input` rides along for the on-demand peek.
67
+ const existing = state.items.some((it) => it.type === "step" && it.id === id);
68
+ if (existing) {
69
+ return { ...state, items: updateStep(state.items, id, (s) => ({ ...s, label: s.label || name, input: chunk.input ?? s.input })) };
70
+ }
35
71
  return {
36
72
  ...state,
37
- steps: [
38
- ...state.steps,
39
- { id: chunk.toolCallId ?? `${name}-${state.steps.length}`, label: name, kind: "tool", status: "done" },
40
- ],
73
+ items: [...state.items, { type: "step", id, label: name, kind: "tool", status: "running", input: chunk.input }],
41
74
  };
42
75
  }
76
+ case "tool-output-available":
77
+ return { ...state, items: updateStep(state.items, chunk.toolCallId, (s) => ({ ...s, status: "done", output: chunk.output })) };
78
+ case "tool-output-error":
79
+ return { ...state, items: updateStep(state.items, chunk.toolCallId, (s) => ({ ...s, status: "error", errorText: chunk.errorText ?? "The tool failed." })) };
43
80
  case "error":
44
81
  return { ...state, status: "error", error: chunk.errorText ?? "The run failed." };
45
82
  case "abort":
46
83
  return { ...state, status: state.status === "streaming" ? "error" : state.status, error: state.error ?? "The run was stopped." };
47
84
  case "finish": {
48
- const output = state.output ?? (state.text.trim() ? state.text.trim() : undefined);
49
- return { ...state, status: state.status === "error" ? "error" : "completed", output };
85
+ // Settle the status, and settle any tool still "running" (its output frame
86
+ // never came) to "done" so nothing spins forever. `output` is whatever
87
+ // `submit_result` set (else undefined) — never the accumulated text: a
88
+ // structured-output consumer reads `output.<field>`, so a stray free-text
89
+ // string there would crash it. The free-text answer is always in `text`.
90
+ const items = state.items.map((it) => (it.type === "step" && it.status === "running" ? { ...it, status: "done" } : it));
91
+ return { ...state, status: state.status === "error" ? "error" : "completed", items };
50
92
  }
51
93
  default:
52
94
  return state;
@@ -1,8 +1,8 @@
1
- import { type AgentRunStep } from "./agent_stream.js";
1
+ import { type AgentRunStep, type AgentRunItem } from "./agent_stream.js";
2
2
  import type { AppWorkflows, AppWorkflowResults, AppQueries, AppAgents, AppAgentResults } from "./types.js";
3
3
  import type { ResolvedMember } from "./members.js";
4
4
  import type { ResolvedOption } from "./select.js";
5
- export type { AgentRunStep, AgentRunState } from "./agent_stream.js";
5
+ export type { AgentRunStep, AgentRunState, AgentRunItem } from "./agent_stream.js";
6
6
  /** Fields shared by every query hook's return value. */
7
7
  interface QueryStateBase {
8
8
  /**
@@ -427,25 +427,41 @@ export interface UseAgentRun<TInput, TOutput> {
427
427
  * user-facing "Stop" button to this, not `abort`. */
428
428
  cancel: () => void;
429
429
  status: "idle" | "streaming" | "completed" | "error";
430
- /** The agent's streamed reasoning / answer text, accumulating live. */
430
+ /** The ordered run transcript prose interleaved with tool steps, in stream
431
+ * order. Feed straight to `@lotics/ui` `AgentRun`:
432
+ * `<AgentRun items={run.items} state={run.status === "streaming" ? "streaming" : run.status === "error" ? "error" : "done"} />`
433
+ * — no hand-assembly. */
434
+ items: AgentRunItem[];
435
+ /** The agent's ANSWER prose (every `text` segment concatenated), accumulating
436
+ * live — excludes thinking (`reasoning` is its own segment in `items`). For a
437
+ * FREE-TEXT agent this IS the result; a structured agent's result is `output`.
438
+ * Derived from `items`. */
431
439
  text: string;
432
- /** The agent's tool calls so far bind to `@lotics/ui` `AgentRun`/`AgentProgress`. */
440
+ /** Only the tool/step items (each with its `input`/`output`/`status`) — backward
441
+ * compat with the older steps-only `AgentRun`. Prefer `items` (it keeps the
442
+ * text↔reasoning↔tool ordering the feed renders). Derived from `items`. */
433
443
  steps: AgentRunStep[];
434
- /** The validated structured result, once the run completes. */
444
+ /** The structured result once the run completes — the agent's `submit_result`
445
+ * output. `undefined` when the run produced none (a free-text agent, or one that
446
+ * finished without submitting); a free-text answer lives in `text`, never here.
447
+ * So it is safe to treat a present `output` as the declared shape — but still
448
+ * validate untrusted inner fields (the model authored the `submit_result` body,
449
+ * so e.g. an array field may be missing/mistyped). */
435
450
  output?: TOutput;
436
451
  error?: string;
437
452
  }
438
453
  /**
439
454
  * Run a streaming agent declared in `package.json` lotics.agents and invoked by
440
- * alias. `run(input, { sessionId })` starts it; progress streams into `status` /
441
- * `text` / `steps` (feed those straight into `@lotics/ui` `AgentRun` or
442
- * `AgentProgress`) and the structured result lands in `output`. Read the
443
- * session's history with `useAgentRuns`.
455
+ * alias. `run(input, { sessionId })` starts it; progress streams into `status` +
456
+ * the ordered `items` transcript (feed straight to `@lotics/ui` `AgentRun`). A
457
+ * STRUCTURED agent's result lands in `output`; a FREE-TEXT agent's answer is the
458
+ * transcript's prose (`text`). Read the session's history with `useAgentRuns`.
444
459
  *
445
460
  * ```tsx
446
461
  * const recognize = useAgentRun("recognize");
447
462
  * await recognize.run({ image_file_id }, { sessionId });
448
- * // recognize.status / recognize.steps / recognize.output
463
+ * // <AgentRun items={recognize.items} state={recognize.status === "streaming" ? "streaming" : "done"} />
464
+ * // then read recognize.output (structured) or recognize.text (free-text)
449
465
  * ```
450
466
  */
451
467
  export declare function useAgentRun<K extends keyof AppAgents & string>(alias: K): UseAgentRun<AppAgents[K], AgentOutputOf<K>>;
package/dist/src/hooks.js CHANGED
@@ -406,8 +406,11 @@ export function useAgentRun(alias) {
406
406
  if (aborted)
407
407
  return undefined;
408
408
  // `finish` normally settles status; guard a stream that ended without one.
409
+ // Keep `output` as whatever `submit_result` set (else undefined) — never
410
+ // coerce the free-text into it (that string would crash a structured
411
+ // consumer reading `output.<field>`; the answer stays in `text`).
409
412
  if (acc.status === "streaming") {
410
- acc = { ...acc, status: "completed", output: acc.output ?? (acc.text.trim() || undefined) };
413
+ acc = { ...acc, status: "completed" };
411
414
  safeSetState(acc);
412
415
  }
413
416
  captureAppEvent("app_agent_run", { alias, ok: acc.status !== "error" });
@@ -448,17 +451,26 @@ export function useAgentRun(alias) {
448
451
  void rpc("agentRun.cancel", { run_id: runId }).catch(() => { });
449
452
  handleRef.current?.abort();
450
453
  }, []);
454
+ // `items` is the source of truth; `text` (all prose) and `steps` (tool items
455
+ // only) are the backward-compat views derived from it.
456
+ const items = state?.items ?? EMPTY_ITEMS;
457
+ const text = useMemo(() => items.reduce((acc, i) => (i.type === "text" ? acc + i.text : acc), ""), [items]);
458
+ 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]);
451
459
  return {
452
460
  run,
453
461
  abort,
454
462
  cancel,
455
463
  status: state?.status ?? "idle",
456
- text: state?.text ?? "",
457
- steps: state?.steps ?? [],
464
+ items,
465
+ text,
466
+ steps,
458
467
  output: state?.output,
459
468
  error: state?.error,
460
469
  };
461
470
  }
471
+ /** Stable empty transcript so an idle hook returns a constant `items` reference
472
+ * (no new [] each render → dependents don't re-run needlessly). */
473
+ const EMPTY_ITEMS = [];
462
474
  /**
463
475
  * Poll a single run until it leaves `running` — the resume path when a run's
464
476
  * stream connection drops mid-flight. Bounded just past the server-side max-run
@@ -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, } from "./hooks.js";
20
- export type { UploadedFile, AttachedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunRecord, AgentRunState, AgentRunStep, 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, AgentRunStep, AgentRunItem, 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.42.0",
3
+ "version": "0.43.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": {