@lotics/app-sdk 0.55.0 → 0.55.2
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/dist/src/agent_stream.d.ts +8 -0
- package/dist/src/agent_stream.js +20 -4
- package/dist/src/hooks.d.ts +8 -0
- package/dist/src/hooks.js +47 -8
- package/dist/src/rpc.d.ts +12 -1
- package/dist/src/rpc.js +13 -3
- package/docs/ai.md +11 -4
- package/docs/runtime.md +6 -2
- package/package.json +2 -5
|
@@ -45,6 +45,14 @@ export interface SettledAgentRun {
|
|
|
45
45
|
status: string;
|
|
46
46
|
output?: unknown;
|
|
47
47
|
error_message?: string | null;
|
|
48
|
+
/** Present on a polled `awaiting_input` row: the pending ask derived from the
|
|
49
|
+
* server-side transcript, so a client that lost the stream (or reloaded) can
|
|
50
|
+
* rebuild the question without ever having received the part. */
|
|
51
|
+
pending_interactive?: {
|
|
52
|
+
tool_call_id: string;
|
|
53
|
+
tool_name: string;
|
|
54
|
+
input: unknown;
|
|
55
|
+
} | null;
|
|
48
56
|
}
|
|
49
57
|
/**
|
|
50
58
|
* Fold a POLLED settled run row into the stream-accumulated state — the shared
|
package/dist/src/agent_stream.js
CHANGED
|
@@ -41,10 +41,26 @@ export function adoptSettledRun(state, settled) {
|
|
|
41
41
|
const structured = settled.output !== null && typeof settled.output === "object" ? settled.output : undefined;
|
|
42
42
|
return { ...state, status: "completed", output: structured ?? state.output };
|
|
43
43
|
}
|
|
44
|
-
// A PARKED row is a live, resumable state —
|
|
45
|
-
//
|
|
44
|
+
// A PARKED row is a live, resumable state — never coerce it into an error.
|
|
45
|
+
// The invariant the hook layer relies on: `awaiting_input` always yields an
|
|
46
|
+
// answerable pending part. When the stream died before the ask part arrived,
|
|
47
|
+
// rebuild it from the row's derived `pending_interactive`.
|
|
46
48
|
if (settled.status === "awaiting_input") {
|
|
47
|
-
|
|
49
|
+
const next = { ...state, status: "awaiting_input" };
|
|
50
|
+
if (pendingInteractiveCall(next))
|
|
51
|
+
return next;
|
|
52
|
+
const p = settled.pending_interactive;
|
|
53
|
+
const alreadyPresent = p && state.parts.some((part) => part.type === "dynamic-tool" && part.toolCallId === p.tool_call_id);
|
|
54
|
+
if (p && INTERACTIVE_TOOLS.has(p.tool_name) && !alreadyPresent) {
|
|
55
|
+
return {
|
|
56
|
+
...next,
|
|
57
|
+
parts: [
|
|
58
|
+
...state.parts,
|
|
59
|
+
{ type: "dynamic-tool", toolName: p.tool_name, toolCallId: p.tool_call_id, state: "input-available", input: p.input },
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return next;
|
|
48
64
|
}
|
|
49
65
|
return { ...state, status: "error", error: settled.error_message ?? "The run was stopped." };
|
|
50
66
|
}
|
|
@@ -103,7 +119,7 @@ export function buildChoiceOutput(questions, answers) {
|
|
|
103
119
|
if (!answer || value.length === 0) {
|
|
104
120
|
return { type: "skipped", question_index: index, question_number: questionNumber, question_text: question.question };
|
|
105
121
|
}
|
|
106
|
-
const optionIndex = answer.custom ? -1 : question.options.findIndex((o) => o.label ===
|
|
122
|
+
const optionIndex = answer.custom ? -1 : question.options.findIndex((o) => o.label === value);
|
|
107
123
|
if (optionIndex >= 0) {
|
|
108
124
|
const option = question.options[optionIndex];
|
|
109
125
|
return {
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -531,6 +531,14 @@ export interface AgentRunRecord {
|
|
|
531
531
|
error_message: string | null;
|
|
532
532
|
started_at: string;
|
|
533
533
|
completed_at: string | null;
|
|
534
|
+
/** Single-run GET only, while `status` is "awaiting_input": the pending ask
|
|
535
|
+
* derived server-side from the transcript — lets a reconnecting client
|
|
536
|
+
* rebuild the question without the live stream. */
|
|
537
|
+
pending_interactive?: {
|
|
538
|
+
tool_call_id: string;
|
|
539
|
+
tool_name: string;
|
|
540
|
+
input: unknown;
|
|
541
|
+
} | null;
|
|
534
542
|
}
|
|
535
543
|
interface AgentRunsState {
|
|
536
544
|
runs: AgentRunRecord[];
|
package/dist/src/hooks.js
CHANGED
|
@@ -467,12 +467,36 @@ export function useAgentRun(alias) {
|
|
|
467
467
|
* connection, so a truncation polls the row instead of surfacing a network
|
|
468
468
|
* error. A leg that ends `awaiting_input` resolves with `undefined`; the
|
|
469
469
|
* final leg resolves with the structured output.
|
|
470
|
+
*
|
|
471
|
+
* `revertTo` (the continuation leg): the leg's initial state is an OPTIMISTIC
|
|
472
|
+
* commit (the ask settled locally before the server accepted the answer). A
|
|
473
|
+
* request that fails before ANY chunk arrives means the server rejected it and
|
|
474
|
+
* the row never left `awaiting_input` — restore the pre-answer state so the
|
|
475
|
+
* question is answerable again, and REJECT so the app surfaces the error.
|
|
476
|
+
* Without this, a 400/409 would be misread as a mid-run connection drop,
|
|
477
|
+
* poll-adopted, and silently swallowed.
|
|
470
478
|
*/
|
|
471
|
-
const streamLeg = useCallback((start, initial) => {
|
|
479
|
+
const streamLeg = useCallback((start, initial, revertTo) => {
|
|
472
480
|
let acc = initial;
|
|
473
481
|
let buffer = "";
|
|
474
482
|
let aborted = false;
|
|
483
|
+
let received = false;
|
|
475
484
|
safeSetState(acc);
|
|
485
|
+
// Enforce the invariant the wizard relies on: `awaiting_input` always
|
|
486
|
+
// yields an answerable pendingChoice. A polled parked row whose question
|
|
487
|
+
// cannot be rebuilt (no part in the stream, none derivable from the row)
|
|
488
|
+
// must fail loud and retryable, never sit as a silent dead end.
|
|
489
|
+
const adoptGuarded = (state, settled) => {
|
|
490
|
+
const adopted = adoptSettledRun(state, settled);
|
|
491
|
+
if (adopted.status === "awaiting_input" && !pendingInteractiveCall(adopted)) {
|
|
492
|
+
return {
|
|
493
|
+
...adopted,
|
|
494
|
+
status: "error",
|
|
495
|
+
error: "The run is waiting for an answer that could not be recovered. Run it again.",
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
return adopted;
|
|
499
|
+
};
|
|
476
500
|
const handle = start((textChunk) => {
|
|
477
501
|
if (aborted)
|
|
478
502
|
return;
|
|
@@ -481,6 +505,7 @@ export function useAgentRun(alias) {
|
|
|
481
505
|
buffer = rest;
|
|
482
506
|
if (chunks.length === 0)
|
|
483
507
|
return;
|
|
508
|
+
received = true;
|
|
484
509
|
for (const c of chunks)
|
|
485
510
|
acc = reduceAgentChunk(acc, c);
|
|
486
511
|
safeSetState({ ...acc });
|
|
@@ -521,7 +546,7 @@ export function useAgentRun(alias) {
|
|
|
521
546
|
// a fake "completed": a structured consumer reading a completed
|
|
522
547
|
// state with no output would render success around a missing result.
|
|
523
548
|
acc = settled
|
|
524
|
-
?
|
|
549
|
+
? adoptGuarded(acc, settled)
|
|
525
550
|
: {
|
|
526
551
|
...acc,
|
|
527
552
|
status: "error",
|
|
@@ -542,6 +567,14 @@ export function useAgentRun(alias) {
|
|
|
542
567
|
.catch(async (err) => {
|
|
543
568
|
if (aborted)
|
|
544
569
|
return undefined;
|
|
570
|
+
// The continue request itself was rejected — the server never resumed
|
|
571
|
+
// the run (400 invalid answer, 409 raced cancel/expiry, network at
|
|
572
|
+
// connect). Restore the pre-answer parked state and surface the error;
|
|
573
|
+
// this is a request failure, not a stream truncation.
|
|
574
|
+
if (revertTo && !received) {
|
|
575
|
+
safeSetState(revertTo);
|
|
576
|
+
throw err;
|
|
577
|
+
}
|
|
545
578
|
// The stream connection dropped, but the run is decoupled from it and
|
|
546
579
|
// keeps executing server-side. Poll the persisted run to completion and
|
|
547
580
|
// surface its result instead of a network error — work is never lost.
|
|
@@ -551,7 +584,7 @@ export function useAgentRun(alias) {
|
|
|
551
584
|
if (aborted)
|
|
552
585
|
return undefined;
|
|
553
586
|
if (settled) {
|
|
554
|
-
acc =
|
|
587
|
+
acc = adoptGuarded(acc, settled);
|
|
555
588
|
safeSetState(acc);
|
|
556
589
|
}
|
|
557
590
|
// The same fleet-visibility beacon as the clean-end path — a dropped
|
|
@@ -593,23 +626,29 @@ export function useAgentRun(alias) {
|
|
|
593
626
|
});
|
|
594
627
|
inflightRef.current = tracked;
|
|
595
628
|
return tracked;
|
|
596
|
-
}, [alias,
|
|
629
|
+
}, [alias, streamLeg]);
|
|
597
630
|
// The run's pending ask — non-null exactly while it is parked on a question.
|
|
598
631
|
const pendingChoice = state ? pendingInteractiveCall(state) : null;
|
|
599
632
|
const answerChoice = useCallback((answers) => {
|
|
633
|
+
// Join first: a double-submit (the wizard's button pressed twice) must
|
|
634
|
+
// land on the in-flight continuation, not reject on the already-settled
|
|
635
|
+
// pending check below.
|
|
636
|
+
if (inflightRef.current)
|
|
637
|
+
return inflightRef.current;
|
|
600
638
|
const current = stateRef.current;
|
|
601
639
|
const runId = runIdRef.current;
|
|
602
640
|
const pending = current ? pendingInteractiveCall(current) : null;
|
|
603
641
|
if (!current || !runId || !pending) {
|
|
604
642
|
return Promise.reject(new Error("No pending question to answer."));
|
|
605
643
|
}
|
|
606
|
-
if (inflightRef.current)
|
|
607
|
-
return inflightRef.current;
|
|
608
644
|
const output = buildChoiceOutput(pending.questions, answers);
|
|
609
|
-
const inflight = streamLeg((onText) => rpcAgentRunContinue({ run_id: runId, tool_call_id: pending.toolCallId, output
|
|
645
|
+
const inflight = streamLeg((onText) => rpcAgentRunContinue({ run_id: runId, tool_call_id: pending.toolCallId, output }, onText),
|
|
610
646
|
// The answered part settles locally (its output rides the feed's
|
|
611
647
|
// on-demand reveal) and the state re-enters streaming for the leg.
|
|
612
|
-
applyInteractiveAnswer(current, pending.toolCallId, output)
|
|
648
|
+
applyInteractiveAnswer(current, pending.toolCallId, output),
|
|
649
|
+
// On a rejected request the leg restores this pre-answer state, so the
|
|
650
|
+
// wizard reappears and the rejection reaches the app.
|
|
651
|
+
current);
|
|
613
652
|
const tracked = inflight.finally(() => {
|
|
614
653
|
if (inflightRef.current === tracked)
|
|
615
654
|
inflightRef.current = null;
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AskUserChoiceOutput } from "./agent_stream.js";
|
|
1
2
|
import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
|
|
2
3
|
/**
|
|
3
4
|
* RPC bridge for a custom-code app's data operations.
|
|
@@ -151,7 +152,7 @@ export interface AgentRunContinuePayload {
|
|
|
151
152
|
run_id: string;
|
|
152
153
|
tool_call_id: string;
|
|
153
154
|
/** The `ask_user_choice` output the user assembled (validated server-side). */
|
|
154
|
-
output:
|
|
155
|
+
output: AskUserChoiceOutput;
|
|
155
156
|
}
|
|
156
157
|
/**
|
|
157
158
|
* Continue a PARKED (`awaiting_input`) agent run with the user's answer to its
|
|
@@ -159,6 +160,16 @@ export interface AgentRunContinuePayload {
|
|
|
159
160
|
* `rpcAgentRun` streams the first.
|
|
160
161
|
*/
|
|
161
162
|
export declare function rpcAgentRunContinue(payload: AgentRunContinuePayload, onText: (chunk: string) => void): AgentRunHandle;
|
|
163
|
+
/**
|
|
164
|
+
* The password-session header. It is deliberately NOT `Authorization`: this
|
|
165
|
+
* token authenticates nobody — it proves the visitor knows the app's shared
|
|
166
|
+
* link password — and the credential header already carries API keys and OAuth
|
|
167
|
+
* bearers. Sharing it meant the server's auth middleware rejected the request
|
|
168
|
+
* as an unknown bearer before the password gate ever ran, which took every
|
|
169
|
+
* password-gated public app offline. Wire constant, mirrored server-side by
|
|
170
|
+
* `APP_PUBLIC_SESSION_HEADER`; both sides are pinned by tests.
|
|
171
|
+
*/
|
|
172
|
+
export declare const APP_PUBLIC_SESSION_HEADER = "x-lotics-app-session";
|
|
162
173
|
/**
|
|
163
174
|
* The error message for a non-ok response. A genuine JSON error (a 4xx carrying
|
|
164
175
|
* a `message`) surfaces verbatim; a non-JSON body (a gateway HTML page), any
|
package/dist/src/rpc.js
CHANGED
|
@@ -250,7 +250,7 @@ export function rpcAgentRunContinue(payload, onText) {
|
|
|
250
250
|
const { app_id } = await boot();
|
|
251
251
|
const headers = { "content-type": "application/json" };
|
|
252
252
|
if (sessionToken)
|
|
253
|
-
headers[
|
|
253
|
+
headers[APP_PUBLIC_SESSION_HEADER] = sessionToken;
|
|
254
254
|
const res = await fetch(`${API_BASE}/v1/apps/${app_id}/agent-runs/${encodeURIComponent(payload.run_id)}/continue`, {
|
|
255
255
|
method: "POST",
|
|
256
256
|
headers,
|
|
@@ -288,7 +288,7 @@ function agentRunStandalone(payload, onText, onRunId) {
|
|
|
288
288
|
const { app_id } = await boot();
|
|
289
289
|
const headers = { "content-type": "application/json" };
|
|
290
290
|
if (sessionToken)
|
|
291
|
-
headers[
|
|
291
|
+
headers[APP_PUBLIC_SESSION_HEADER] = sessionToken;
|
|
292
292
|
const res = await fetch(`${API_BASE}/v1/apps/${app_id}/agents/${encodeURIComponent(payload.alias)}/runs`, {
|
|
293
293
|
method: "POST",
|
|
294
294
|
headers,
|
|
@@ -336,6 +336,16 @@ const PASSWORD_REQUIRED_CODE = "PASSWORD_REQUIRED";
|
|
|
336
336
|
let bootPromise = null;
|
|
337
337
|
let appInfoPromise = null;
|
|
338
338
|
let sessionToken = null;
|
|
339
|
+
/**
|
|
340
|
+
* The password-session header. It is deliberately NOT `Authorization`: this
|
|
341
|
+
* token authenticates nobody — it proves the visitor knows the app's shared
|
|
342
|
+
* link password — and the credential header already carries API keys and OAuth
|
|
343
|
+
* bearers. Sharing it meant the server's auth middleware rejected the request
|
|
344
|
+
* as an unknown bearer before the password gate ever ran, which took every
|
|
345
|
+
* password-gated public app offline. Wire constant, mirrored server-side by
|
|
346
|
+
* `APP_PUBLIC_SESSION_HEADER`; both sides are pinned by tests.
|
|
347
|
+
*/
|
|
348
|
+
export const APP_PUBLIC_SESSION_HEADER = "x-lotics-app-session";
|
|
339
349
|
function sessionStorageKey(appId) {
|
|
340
350
|
return `lotics_app_session:${appId}`;
|
|
341
351
|
}
|
|
@@ -478,7 +488,7 @@ async function apiCall(method, path, body, opts) {
|
|
|
478
488
|
if (body)
|
|
479
489
|
headers["content-type"] = "application/json";
|
|
480
490
|
if (sessionToken && !opts?.skipAuth) {
|
|
481
|
-
headers[
|
|
491
|
+
headers[APP_PUBLIC_SESSION_HEADER] = sessionToken;
|
|
482
492
|
}
|
|
483
493
|
const controller = new AbortController();
|
|
484
494
|
let didTimeout = false;
|
package/docs/ai.md
CHANGED
|
@@ -54,7 +54,7 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
|
|
|
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" \| "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
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 |
|
|
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 — and when the server refuses the answer, in which case the pending question is restored for a retry |
|
|
58
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` |
|
|
59
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 |
|
|
60
60
|
| `output` | `TOutput \| undefined` | The structured result once the run completes. `undefined` when the run produced none |
|
|
@@ -109,9 +109,16 @@ const run = useAgentRun("importer");
|
|
|
109
109
|
|
|
110
110
|
The ask renders in the feed as a settled tool row once answered (the answer rides its
|
|
111
111
|
on-demand reveal). `run()`'s promise resolves `undefined` when the run parks — the
|
|
112
|
-
continuation's promise (from `answerChoice`) carries the final output.
|
|
113
|
-
run
|
|
114
|
-
|
|
112
|
+
continuation's promise (from `answerChoice`) carries the final output. The question is as
|
|
113
|
+
connection-decoupled as the run: a dropped stream can't lose it — the hook's recovery poll
|
|
114
|
+
rebuilds the pending question from the persisted run, so `awaiting_input` always yields an
|
|
115
|
+
answerable `pendingChoice` (the one unrecoverable corner surfaces a retryable `error`, never
|
|
116
|
+
a silent dead end). If the server refuses an answer (an invalid submission, a raced cancel
|
|
117
|
+
or expiry, a connection failure), `answerChoice` REJECTS and the pending question is
|
|
118
|
+
restored — surface the error and let the user submit again; nothing is half-committed.
|
|
119
|
+
`cancel()` on a parked run settles it `aborted` immediately; an unanswered park expires
|
|
120
|
+
after an hour (`error`, "timed out waiting for an answer"). Multi-ask runs work — each
|
|
121
|
+
answer may park again.
|
|
115
122
|
|
|
116
123
|
### `output` typing and the inner-field caveat
|
|
117
124
|
|
package/docs/runtime.md
CHANGED
|
@@ -132,8 +132,12 @@ coalesce; a transient failure isn't cached, the next call retries). If the app
|
|
|
132
132
|
is password-protected, the SDK renders its own full-screen password overlay
|
|
133
133
|
(plain DOM, so it works before React data arrives), exchanges the password for a
|
|
134
134
|
session token, and stores it in `localStorage` under
|
|
135
|
-
`lotics_app_session:<app_id>` with its expiry. The token rides
|
|
136
|
-
|
|
135
|
+
`lotics_app_session:<app_id>` with its expiry. The token rides on subsequent
|
|
136
|
+
calls in its own `X-Lotics-App-Session` header — deliberately not
|
|
137
|
+
`Authorization`, which carries API keys and OAuth bearers: this token
|
|
138
|
+
authenticates nobody, it proves the visitor knows the shared password, and
|
|
139
|
+
sharing the credential header made the auth layer reject it before the password
|
|
140
|
+
gate could read it. A `401` with error code `PASSWORD_REQUIRED` (the
|
|
137
141
|
owner rotated or cleared the password) drops the stored token, re-prompts, and
|
|
138
142
|
retries the original call once. There is no cancel button — the visitor enters
|
|
139
143
|
the password or leaves. When `localStorage` is unavailable (private browsing,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.55.
|
|
3
|
+
"version": "0.55.2",
|
|
4
4
|
"description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -26,19 +26,16 @@
|
|
|
26
26
|
"prepublishOnly": "npm run build"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
+
"ai": "^7.0.30",
|
|
29
30
|
"posthog-js": "^1.352.0",
|
|
30
31
|
"swr": "^2.4.1"
|
|
31
32
|
},
|
|
32
33
|
"peerDependencies": {
|
|
33
|
-
"ai": ">=7.0.0",
|
|
34
34
|
"react": "^19.2.0",
|
|
35
35
|
"react-dom": "^19.2.0",
|
|
36
36
|
"react-router-dom": "^7.0.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependenciesMeta": {
|
|
39
|
-
"ai": {
|
|
40
|
-
"optional": true
|
|
41
|
-
},
|
|
42
39
|
"react-router-dom": {
|
|
43
40
|
"optional": true
|
|
44
41
|
}
|