@lotics/app-sdk 0.54.1 → 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 +1 -1
- package/dist/src/agent_stream.d.ts +63 -1
- package/dist/src/agent_stream.js +104 -2
- package/dist/src/hooks.d.ts +19 -3
- package/dist/src/hooks.js +61 -20
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/row.d.ts +3 -3
- package/dist/src/rpc.d.ts +12 -0
- package/dist/src/rpc.js +64 -0
- package/docs/ai.md +34 -1
- package/docs/files.md +8 -3
- package/package.json +1 -1
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 {
|
package/dist/src/agent_stream.js
CHANGED
|
@@ -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" &&
|
|
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
|
-
|
|
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;
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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,
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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 `
|
|
78
|
-
* register: a right-aligned "Size" column, formatted at display) —
|
|
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/docs/files.md
CHANGED
|
@@ -52,13 +52,18 @@ use-access to the app itself.
|
|
|
52
52
|
The pipeline is: optimize (images only) → mint a presigned upload URL → `PUT` the bytes straight
|
|
53
53
|
to object storage → finalize. The API server never proxies the bytes.
|
|
54
54
|
|
|
55
|
-
- **Image optimization.** JPEG/PNG/WebP
|
|
55
|
+
- **Image optimization.** JPEG/PNG/WebP images larger than 1280 px on the long edge are
|
|
56
56
|
resized to ≤1280 px and re-encoded as JPEG at quality 0.75 before upload — phone photos
|
|
57
57
|
typically shrink 10–20×. Non-image files pass through unchanged, and any optimization failure
|
|
58
58
|
falls back to uploading the original bytes (never an upload error).
|
|
59
|
-
**Warning:** PNG/WebP
|
|
59
|
+
**Warning:** PNG/WebP larger than 1280 px on the long edge are *converted to JPEG* —
|
|
60
60
|
transparency is lost and the stored filename's extension becomes `.jpg`. If you need lossless
|
|
61
|
-
originals, keep
|
|
61
|
+
originals, keep PNG/WebP ≤1280 px or upload them as non-image MIME types.
|
|
62
|
+
- **HEIC/HEIF always becomes JPEG, regardless of size.** Most browsers cannot decode HEIC
|
|
63
|
+
(the Samsung/iPhone camera default), so where the browser can't convert it client-side the
|
|
64
|
+
server converts it at finalize under the same ≤1280 px / q0.75 policy (conversion failure
|
|
65
|
+
stores the original bytes — never an upload error). Either way the stored file — and the
|
|
66
|
+
file identity your app gets back — is `name.jpg` with `mime_type: "image/jpeg"`.
|
|
62
67
|
- **Transport resilience.** The storage `PUT` has a 5-minute per-request timeout and retries
|
|
63
68
|
network errors / timeouts / 5xx up to 3 attempts with 1 s → 2 s backoff between attempts. 4xx
|
|
64
69
|
responses are terminal (no retry). The presigned upload URL itself is valid for 10 minutes.
|